diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1435231 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +/.devenv/ +/.direnv/ +/.github/ +/bin/ +/build/ +/deploy/ +/Dockerfile +/e2e/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5729235 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[{*.yaml,*.yml}] +indent_size = 2 + +[{Makefile,*.mk}] +indent_style = tab + +[*.nix] +indent_size = 2 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3ce7171 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.3.0; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.3.0/direnvrc" "sha256-Dmd+j63L84wuzgyjITIfSxSD57Tx7v51DMxVZOsiUD8=" +fi +use flake . --impure diff --git a/.github/.editorconfig b/.github/.editorconfig new file mode 100644 index 0000000..0902c6a --- /dev/null +++ b/.github/.editorconfig @@ -0,0 +1,2 @@ +[{*.yml,*.yaml}] +indent_size = 2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..bb52d62 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# This file provides an overview of code owners in this repository. + +# Each line is a file pattern followed by one or more owners. +# The last matching pattern has the most precedence. +# For more details read the following article on GitHub: https://help.github.com/articles/about-codeowners/. + +# These are the default owners for the whole content of repository. +# The default owners are automatically added as reviewers when you open a pull request unless different owners are specified in the file. +* @bank-vaults/maintainers diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..d9d2636 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,107 @@ +name: 🐛 Bug report +description: Report a bug to help us improve the Secrets Webhook +labels: [kind/bug] +body: + - type: markdown + attributes: + value: | + Thank you for submitting a bug report! + + Please fill out the template below to make it easier to debug your problem. + + If you are not sure if it is a bug or not, you can contact us via the available [support channels](https://bank-vaults.dev/docs/support/). + - type: checkboxes + attributes: + label: Preflight Checklist + description: Please ensure you've completed all of the following. + options: + - label: I have searched the [issue tracker](https://www.github.com/bank-vaults/secrets-webhook/issues) for an issue that matches the one I want to file, without success. + required: true + - label: I am not looking for support or already pursued the available [support channels](https://bank-vaults.dev/docs/support/) without success. + required: true + - label: I agree to follow the [Code of Conduct](https://bank-vaults.dev/docs/code-of-conduct/). + required: true + - type: input + attributes: + label: Secrets Webhook Version + description: What version of the Secrets Webhook are you using? + placeholder: 1.20.0 + validations: + required: true + - type: dropdown + attributes: + label: Installation Type + description: How did you install the Operator? + options: + - Official Helm chart + - Custom Helm chart + - Other (specify below) + - type: input + attributes: + label: Bank-Vaults Version + description: What version of the Bank-Vaults CLI are you using? + placeholder: leave empty if you haven't specified a custom version + - type: input + attributes: + label: Kubernetes Version + description: What version of Kubernetes are you using? + placeholder: 1.27.0 + validations: + required: true + - type: input + attributes: + label: Kubernetes Distribution/Provisioner + description: Which Kubernetes distribution/privisioner are you using? + placeholder: e.g. GKE, EKS, AKS, etc + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Actual Behavior + description: A clear description of what actually happens. + validations: + required: true + - type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior if it is not self-explanatory. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + - type: textarea + attributes: + label: Configuration + description: Include Webhook deployment configuration data such as Helm chart values. + render: yaml + placeholder: | + **Chart values** + + Your redacted custom Helm values data + + **Other** + + 1. Vault CustomResource + ```yaml + apiVersion: "vault.banzaicloud.com/v1alpha1" + kind: "Vault" + metadata: + name: "vault" + ... + ``` + - type: textarea + attributes: + label: Logs + description: Webhook or application logs (if relevant). + render: shell + - type: textarea + attributes: + label: Additional Information + description: Links? References? Anything that will give us more context about the issue that you are encountering! diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ee8e12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +blank_issues_enabled: true +contact_links: + - name: 📖 Documentation enhancement + url: https://github.com/bank-vaults/bank-vaults.dev/issues + about: Suggest an improvement to the documentation + + - name: 📚 Documentation + url: https://bank-vaults.dev/docs/mutating-webhook/ + about: Check the documentation for help + + - name: 💬 Slack channel + url: https://eti.cisco.com/slack + about: Please ask and answer questions here diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..c3a735a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,41 @@ +name: 🎉 Feature request +description: Suggest an idea for the Secrets Webhook +labels: [kind/enhancement] +body: + - type: markdown + attributes: + value: | + Thank you for submitting a feature request! + + Please describe what you would like to change/add and why in detail by filling out the template below. + + If you are not sure if your request fits into Dex, you can contact us via the available [support channels](https://bank-vaults.dev/docs/support/). + - type: checkboxes + attributes: + label: Preflight Checklist + description: Please ensure you've completed all of the following. + options: + - label: I have searched the [issue tracker](https://www.github.com/bank-vaults/secrets-webhook/issues) for an issue that matches the one I want to file, without success. + required: true + - label: I agree to follow the [Code of Conduct](https://bank-vaults.dev/docs/code-of-conduct/). + required: true + - type: textarea + attributes: + label: Problem Description + description: A clear and concise description of the problem you are seeking to solve with this feature request. + validations: + required: true + - type: textarea + attributes: + label: Proposed Solution + description: A clear and concise description of what would you like to happen. + validations: + required: true + - type: textarea + attributes: + label: Alternatives Considered + description: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + attributes: + label: Additional Information + description: Add any other relevant context here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8f72e9c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ + + +## Overview + + + +Fixes #(issue) + +## Notes for reviewer + + diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..81398d7 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,32 @@ +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "16:00" + open-pull-requests-limit: 10 + groups: + k8s: + patterns: + - "k8s.io/api" + - "k8s.io/apimachinery" + - "k8s.io/client-go" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "16:00" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "16:00" + open-pull-requests-limit: 10 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..6edfd4f --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "schedule:earlyMondays", + ":disableDependencyDashboard" + ], + "enabledManagers": [ + "nix" + ], + "nix": { + "enabled": true + }, + "lockFileMaintenance": { + "enabled": true + } +} diff --git a/.github/workflows/analysis-scorecard.yaml b/.github/workflows/analysis-scorecard.yaml new file mode 100644 index 0000000..6798fee --- /dev/null +++ b/.github/workflows/analysis-scorecard.yaml @@ -0,0 +1,47 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + push: + branches: [main] + schedule: + - cron: '30 0 * * 5' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + permissions: + actions: read + contents: read + id-token: write + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload results as artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: OpenSSF Scorecard results + path: results.sarif + retention-days: 5 + + - name: Upload results to GitHub Security tab + uses: github/codeql-action/upload-sarif@cdcdbb579706841c47f7063dda365e292e5cad7a # v2.3.4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/artifacts.yaml b/.github/workflows/artifacts.yaml new file mode 100644 index 0000000..042fef6 --- /dev/null +++ b/.github/workflows/artifacts.yaml @@ -0,0 +1,268 @@ +name: Artifacts + +on: + workflow_call: + inputs: + publish: + description: Publish artifacts to the artifact store + default: false + required: false + type: boolean + release: + description: Whether this is a release build + default: false + required: false + type: boolean + outputs: + container-image-name: + description: Container image name + value: ${{ jobs.container-image.outputs.name }} + container-image-digest: + description: Container image digest + value: ${{ jobs.container-image.outputs.digest }} + container-image-tag: + description: Container image tag + value: ${{ jobs.container-image.outputs.tag }} + container-image-ref: + description: Container image ref + value: ${{ jobs.container-image.outputs.ref }} + helm-chart-name: + description: Helm chart OCI name + value: ${{ jobs.helm-chart.outputs.name }} + helm-chart-tag: + description: Helm chart tag + value: ${{ jobs.helm-chart.outputs.tag }} + helm-chart-package: + description: Helm chart package name + value: ${{ jobs.helm-chart.outputs.package }} + +permissions: + contents: read + +jobs: + container-image: + name: Container image + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + id-token: write + security-events: write + + outputs: + name: ${{ steps.image-name.outputs.value }} + digest: ${{ steps.build.outputs.digest }} + tag: ${{ steps.meta.outputs.version }} + ref: ${{ steps.image-ref.outputs.value }} + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + - name: Set image name + id: image-name + run: echo "value=ghcr.io/${{ github.repository }}" >> "$GITHUB_OUTPUT" + + - name: Gather build metadata + id: meta + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + with: + images: ${{ steps.image-name.outputs.value }} + flavor: | + latest = false + tags: | + type=ref,event=branch + type=ref,event=pr,prefix=pr- + type=semver,pattern={{raw}} + type=raw,value=latest,enable={{is_default_branch}} + + # Multiple exporters are not supported yet + # See https://github.com/moby/buildkit/pull/2760 + - name: Determine build output + uses: haya14busa/action-cond@94f77f7a80cd666cb3155084e428254fea4281fd # v1.2.1 + id: build-output + with: + cond: ${{ inputs.publish }} + if_true: type=image,push=true + if_false: type=oci,dest=image.tar + + - name: Login to GitHub Container Registry + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + if: inputs.publish + + - name: Build and push image + id: build + uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: ${{ steps.build-output.outputs.value }} + # push: ${{ inputs.publish }} + + - name: Set image ref + id: image-ref + run: echo "value=${{ steps.image-name.outputs.value }}@${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT" + + - name: Fetch image + run: skopeo --insecure-policy copy docker://${{ steps.image-name.outputs.value }}:${{ steps.meta.outputs.version }} oci-archive:image.tar + if: inputs.publish + + - name: Upload image as artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: "[${{ github.job }}] OCI tarball" + path: image.tar + + - name: Extract OCI tarball + run: | + mkdir -p image + tar -xf image.tar -C image + + # See https://github.com/anchore/syft/issues/1545 + - name: Extract image from multi-arch image + run: skopeo --override-os linux --override-arch amd64 --insecure-policy copy --additional-tag ${{ steps.image-name.outputs.value }}:${{ steps.meta.outputs.version }} oci:image docker-archive:docker.tar + + - name: Upload image as artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: "[${{ github.job }}] Docker tarball" + path: docker.tar + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@84384bd6e777ef152729993b8145ea352e9dd3ef # 0.17.0 + with: + input: image + format: sarif + output: trivy-results.sarif + + - name: Upload Trivy scan results as artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: "[${{ github.job }}] Trivy scan results" + path: trivy-results.sarif + retention-days: 5 + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@cdcdbb579706841c47f7063dda365e292e5cad7a # v2.13.4 + with: + sarif_file: trivy-results.sarif + + helm-chart: + name: Helm chart + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + id-token: write + security-events: write + + outputs: + name: ${{ steps.oci-chart-name.outputs.value }} + tag: ${{ steps.version.outputs.value }} + package: ${{ steps.build.outputs.package }} + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Helm + uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 + with: + version: v3.12.0 + + - name: Set chart name + id: chart-name + run: echo "value=${{ github.event.repository.name }}" >> "$GITHUB_OUTPUT" + + - name: Set OCI registry name + id: oci-registry-name + run: echo "value=ghcr.io/${{ github.repository_owner }}/helm-charts" >> "$GITHUB_OUTPUT" + + - name: Set OCI chart name + id: oci-chart-name + run: echo "value=${{ steps.oci-registry-name.outputs.value }}/${{ steps.chart-name.outputs.value }}" >> "$GITHUB_OUTPUT" + + - name: Helm lint + run: helm lint deploy/charts/${{ steps.chart-name.outputs.value }} + + - name: Determine raw version + uses: haya14busa/action-cond@94f77f7a80cd666cb3155084e428254fea4281fd # v1.2.1 + id: raw-version + with: + cond: ${{ inputs.release }} + if_true: ${{ github.ref_name }} + if_false: v0.0.0 + + - name: Determine version + id: version + run: | + VERSION=${{ steps.raw-version.outputs.value }} + echo "value=${VERSION#v}" >> "$GITHUB_OUTPUT" + + - name: Helm package + id: build + run: | + helm package deploy/charts/${{ steps.chart-name.outputs.value }} --version ${{ steps.version.outputs.value }} --app-version ${{ steps.raw-version.outputs.value }} + echo "package=${{ steps.chart-name.outputs.value }}-${{ steps.version.outputs.value }}.tgz" >> "$GITHUB_OUTPUT" + + - name: Upload chart as artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: "[${{ github.job }}] Helm chart" + path: ${{ steps.build.outputs.package }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + if: inputs.publish && inputs.release + + - name: Helm push + run: helm push ${{ steps.build.outputs.package }} oci://${{ steps.oci-registry-name.outputs.value }} + env: + HELM_REGISTRY_CONFIG: ~/.docker/config.json + if: inputs.publish && inputs.release + + - name: Upload package as artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: "[${{ github.job }}] package" + path: ${{ steps.build.outputs.package }} + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@84384bd6e777ef152729993b8145ea352e9dd3ef # 0.17.0 + with: + scan-type: config + scan-ref: deploy/charts/${{ steps.chart-name.outputs.value }} + format: sarif + output: trivy-results.sarif + + - name: Upload Trivy scan results as artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: "[${{ github.job }}] Trivy scan results" + path: trivy-results.sarif + retention-days: 5 + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@cdcdbb579706841c47f7063dda365e292e5cad7a # v2.13.4 + with: + sarif_file: trivy-results.sarif diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..bb1539e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,268 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github. run_id }} + cancel-in-progress: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Nix + uses: cachix/install-nix-action@6004951b182f8860210c8d6f0d808ec5b1a33d28 # v25 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - name: Set up magic Nix cache + uses: DeterminateSystems/magic-nix-cache-action@eeabdb06718ac63a7021c6132129679a8e22d0c7 # v3 + + - name: Set up Go cache + uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-go- + + - name: Prepare Nix shell + run: nix develop --impure .#ci + + - name: Build + run: nix develop --impure .#ci -c make build + + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + vault_version: ["1.11.12", "1.12.8", "1.13.4", "1.14.8"] + + services: + vault: + image: hashicorp/vault:${{ matrix.vault_version }} + env: + SKIP_SETCAP: true + VAULT_DEV_ROOT_TOKEN_ID: 227e1cce-6bf7-30bb-2d2a-acc854318caf + ports: + - 8200:8200 + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Nix + uses: cachix/install-nix-action@6004951b182f8860210c8d6f0d808ec5b1a33d28 # v25 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - name: Set up magic Nix cache + uses: DeterminateSystems/magic-nix-cache-action@eeabdb06718ac63a7021c6132129679a8e22d0c7 # v3 + + - name: Set up Go cache + uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-go- + + - name: Prepare Nix shell + run: nix develop --impure .#ci + + - name: Test + run: nix develop --impure .#ci -c make test + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Nix + uses: cachix/install-nix-action@6004951b182f8860210c8d6f0d808ec5b1a33d28 # v25 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - name: Set up magic Nix cache + uses: DeterminateSystems/magic-nix-cache-action@eeabdb06718ac63a7021c6132129679a8e22d0c7 # v3 + + - name: Set up Go cache + uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-go- + + - name: Prepare Nix shell + run: nix develop --impure .#ci + + - name: Lint + run: nix develop --impure .#ci -c make lint -j + + license-check: + name: License check + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Nix + uses: cachix/install-nix-action@6004951b182f8860210c8d6f0d808ec5b1a33d28 # v25 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - name: Set up magic Nix cache + uses: DeterminateSystems/magic-nix-cache-action@eeabdb06718ac63a7021c6132129679a8e22d0c7 # v3 + + - name: Set up Go cache + uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ github.job }}-${{ runner.os }}-go- + + - name: Cache license information + uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0 + with: + path: .licensei.cache + key: licensei-v1-${{ github.ref_name }}-${{ hashFiles('go.sum') }} + restore-keys: | + licensei-v1-${{ github.ref_name }} + licensei-v1-main + licensei-v1 + + - name: Prepare Nix shell + run: nix develop --impure .#ci + + - name: Populate license cache + run: nix develop --impure .#ci -c licensei cache + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check licenses + run: nix develop --impure .#ci -c make license-check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dev: + name: Developer environment + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Nix + uses: cachix/install-nix-action@6004951b182f8860210c8d6f0d808ec5b1a33d28 # v25 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - name: Set up magic Nix cache + uses: DeterminateSystems/magic-nix-cache-action@eeabdb06718ac63a7021c6132129679a8e22d0c7 # v3 + + - name: Check + run: nix flake check --impure + + - name: Dev shell + run: nix develop --impure + + artifacts: + name: Artifacts + uses: ./.github/workflows/artifacts.yaml + with: + publish: ${{ github.event_name == 'push' }} + permissions: + contents: read + packages: write + id-token: write + security-events: write + + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Dependency Review + uses: actions/dependency-review-action@80f10bf419f34980065523f5efca7ebed17576aa # v4.1.0 + + e2e-test: + name: E2E test + runs-on: ubuntu-latest + needs: [artifacts] + strategy: + matrix: + k8s_version: ["v1.24.15", "v1.25.11", "v1.26.6", "v1.27.3"] + # vault_version: ["1.11.12", "1.12.8", "1.13.4", "1.14.8"] + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Nix + uses: cachix/install-nix-action@6004951b182f8860210c8d6f0d808ec5b1a33d28 # v25 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - name: Set up magic Nix cache + uses: DeterminateSystems/magic-nix-cache-action@eeabdb06718ac63a7021c6132129679a8e22d0c7 # v3 + + - name: Prepare Nix shell + run: nix develop --impure .#ci + + - name: Download docker image + uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + with: + name: "[container-image] Docker tarball" + + - name: Download helm chart + uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + with: + name: "[helm-chart] package" + + - name: Test + # Workaround until we have a release + # run: nix develop --impure .#ci -c make test-e2e + run: nix develop --impure .#ci -c make test-e2e-local + env: + KIND_K8S_VERSION: ${{ matrix.k8s_version } + LOAD_IMAGE_ARCHIVE: ${{ github.workspace }}/docker.tar + # VAULT_VERSION: ${{ matrix.vault_version }} + WEBHOOK_VERSION: ${{ needs.artifacts.outputs.container-image-tag }} + HELM_CHART: "${{ github.workspace }}/${{ needs.artifacts.outputs.helm-chart-package }}" + LOG_VERBOSE: "true" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..150b296 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,16 @@ +name: PR + +on: + pull_request_target: + types: + - opened + - edited + - reopened + - synchronize + +jobs: + common: + uses: bank-vaults/.github/.github/workflows/_pr-common.yml@main + permissions: + pull-requests: write + issues: write diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml new file mode 100644 index 0000000..dbfcfac --- /dev/null +++ b/.github/workflows/project.yml @@ -0,0 +1,13 @@ +name: Project + +on: + schedule: + - cron: "0 0 * * 0" + workflow_dispatch: + +jobs: + common: + uses: bank-vaults/.github/.github/workflows/_project-common.yml@main + permissions: + issues: write + pull-requests: write diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..9ef4ae5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,23 @@ +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-dev.[0-9]+" + +permissions: + contents: read + +jobs: + artifacts: + name: Artifacts + uses: ./.github/workflows/artifacts.yaml + with: + publish: true + release: true + permissions: + contents: read + packages: write + id-token: write + security-events: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..155edd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/.devenv/ +/.direnv/ +/.garden/ +/.pre-commit-config.yaml +/bin/ +/build/ +/tmp/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..2146acc --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,27 @@ +run: + timeout: 10m + +linters-settings: + gci: + sections: + - standard + - default + - prefix(github.com/bank-vaults/secrets-webhook) + goimports: + local-prefixes: github.com/bank-vaults/secrets-webhook + misspell: + locale: US + nolintlint: + allow-leading-space: false # require machine-readable nolint directives (with no leading space) + allow-unused: false # report any unused nolint directives + require-specific: false # don't require nolint directives to be specific about which linter is being skipped + revive: + confidence: 0 + +linters: + enable: + - gci + - goimports + - misspell + - nolintlint + - revive diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..eee9f9b --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,3 @@ +ignored: + - DL3018 + - DL3059 diff --git a/.licensei.toml b/.licensei.toml new file mode 100644 index 0000000..3a7b046 --- /dev/null +++ b/.licensei.toml @@ -0,0 +1,56 @@ +approved = [ + "mit", + "apache-2.0", + "bsd-3-clause", + "bsd-2-clause", + "mpl-2.0", + "isc" +] + +ignored = [ + "github.com/aliyun/aliyun-oss-go-sdk", # Apache License 2.0. + "github.com/ghodss/yaml", # MIT + "sigs.k8s.io/yaml", # Forked from above + "github.com/gogo/protobuf", # 3-Clause BSD + "logur.dev/adapter/logrus", # MIT + "logur.dev/logur", # MIT + "emperror.dev/errors", # MIT + "github.com/hashicorp/vault/api", # Mozilla Public License 2.0 + "github.com/hashicorp/vault/api/auth/aws", # Mozilla Public License 2.0 + "github.com/hashicorp/vault/api/auth/azure", # Mozilla Public License 2.0 + "github.com/hashicorp/vault/api/auth/gcp", # Mozilla Public License 2.0 + "github.com/hashicorp/vault/api/auth/kubernetes", # Mozilla Public License 2.0 + + # Unsupported VCS + "gopkg.in/fsnotify.v1", + "gopkg.in/square/go-jose.v2", # Apache 2.0 + "google.golang.org/grpc", + "google.golang.org/genproto", + "google.golang.org/api", + "google.golang.org/protobuf", + "cloud.google.com/go", + "cloud.google.com/go/storage", + + "gomodules.xyz/orderedmap", # MIT + "github.com/form3tech-oss/jwt-go", # MIT + "gomodules.xyz/jsonpatch/v2", # Apache 2.0 + "gomodules.xyz/jsonpatch/v3", # Apache 2.0 +] + +[header] +authors = ["Banzai Cloud", "Bank-Vaults Maintainers"] +ignorePaths = [".direnv", ".devenv", "vendor"] +ignoreFiles = ["zz_generated.*.go"] +template = """// Copyright © :YEAR: :AUTHOR: +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.""" diff --git a/.yamlignore b/.yamlignore new file mode 100644 index 0000000..ac0593e --- /dev/null +++ b/.yamlignore @@ -0,0 +1,3 @@ +/deploy/ +/e2e/deploy/ +/e2e/test/ diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..bac19ce --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,6 @@ +ignore-from-file: [.gitignore, .yamlignore] + +extends: default + +rules: + line-length: disable diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2990745 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.4.0@sha256:0cd3f05c72d6c9b038eb135f91376ee1169ef3a330d34e418e65e2a5c2e9c0d4 AS xx + +FROM --platform=$BUILDPLATFORM golang:1.22.0-alpine3.18@sha256:2745a45f77ae2e7be569934fa9a111f067d04c767f54577e251d9b101250e46b AS builder + +COPY --from=xx / / + +RUN apk add --update --no-cache ca-certificates make git curl clang lld + +ARG TARGETPLATFORM + +RUN xx-apk --update --no-cache add musl-dev gcc + +RUN xx-go --wrap + +WORKDIR /usr/local/src/secrets-webhook + +ARG GOPROXY + +ENV CGO_ENABLED=0 + +COPY go.* ./ +RUN go mod download + +COPY . . + +RUN go build -o /usr/local/bin/secrets-webhook . +RUN xx-verify /usr/local/bin/secrets-webhook + + +FROM alpine:3.19.1@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b + +RUN apk add --update --no-cache ca-certificates tzdata libcap + +COPY --from=builder /usr/local/bin/secrets-webhook /usr/local/bin/secrets-webhook + +USER 65534 + +ENTRYPOINT ["secrets-webhook"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c78dab4 --- /dev/null +++ b/Makefile @@ -0,0 +1,176 @@ +# A Self-Documenting Makefile: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html + +export PATH := $(abspath bin/):${PATH} + +CONTAINER_IMAGE_REF = ghcr.io/bank-vaults/secrets-webhook:dev + +##@ General + +# Targets commented with ## will be visible in "make help" info. +# Comments marked with ##@ will be used as categories for a group of targets. + +.PHONY: help +default: help +help: ## Display this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Development + +.PHONY: up +up: ## Start development environment + $(KIND_BIN) create cluster + docker compose up -d + +.PHONY: down +down: ## Destroy development environment + $(KIND_BIN) delete cluster + docker compose down -v + +.PHONY: run +run: ## Run the operator locally talking to a Kubernetes cluster + KUBERNETES_NAMESPACE=vault-infra go run . + +.PHONY: forward +forward: ## Install the webhook chart and kurun to port-forward the local webhook into Kubernetes + kubectl create namespace vault-infra --dry-run -o yaml | kubectl apply -f - + kubectl label namespaces vault-infra name=vault-infra --overwrite + $(HELM_BIN) upgrade --install secrets-webhook deploy/charts/secrets-webhook --namespace vault-infra --set replicaCount=0 --set podsFailurePolicy=Fail --set secretsFailurePolicy=Fail --set configMapMutation=true --set configMapFailurePolicy=Fail + $(KURUN_BIN) port-forward localhost:8443 --namespace vault-infra --servicename secrets-webhook --tlssecret secrets-webhook-webhook-tls + +##@ Build + +.PHONY: build +build: ## Build binary + @mkdir -p build + go build -race -o build/webhook . + +.PHONY: container-image +container-image: ## Build container image + docker build -t ${CONTAINER_IMAGE_REF} . + +.PHONY: helm-chart +helm-chart: ## Build Helm chart + @mkdir -p build + $(HELM_BIN) package -d build/ deploy/charts/secrets-webhook + +.PHONY: artifacts +artifacts: container-image helm-chart +artifacts: ## Build docker image and helm chart + +##@ Checks + +.PHONY: check +check: test lint ## Run lint checks and tests + +.PHONY: test +test: ## Run tests + go test -race -v ./... + +.PHONY: test-e2e +test-e2e: ## Run e2e tests + go test -race -v -timeout 900s -tags e2e ./e2e/ + +.PHONY: test-e2e-local +test-e2e-local: container-image ## Run e2e tests locally + LOAD_IMAGE=${CONTAINER_IMAGE_REF} WEBHOOK_VERSION=dev ${MAKE} test-e2e + +.PHONY: lint +lint: lint-go lint-helm lint-docker lint-yaml +lint: ## Run linters + +.PHONY: lint-go +lint-go: + $(GOLANGCI_LINT_BIN) run $(if ${CI},--out-format github-actions,) + +.PHONY: lint-helm +lint-helm: + $(HELM_BIN) lint deploy/charts/secrets-webhook + +.PHONY: lint-docker +lint-docker: + $(HADOLINT_BIN) Dockerfile + +.PHONY: lint-yaml +lint-yaml: + $(YAMLLINT_BIN) $(if ${CI},-f github,) --no-warnings . + +.PHONY: license-check +license-check: ## Run license check + $(LICENSEI_BIN) check + $(LICENSEI_BIN) header + +.PHONY: fmt +fmt: ## Format code + $(GOLANGCI_LINT_BIN) run --fix + +##@ Autogeneration + +.PHONY: generate +generate: generate-helm-docs +generate: ## Run generation jobs + +.PHONY: generate-helm-docs +generate-helm-docs: + $(HELM_DOCS_BIN) -s file -c deploy/charts/ -t README.md.gotmpl + +##@ Dependencies + +deps: bin/golangci-lint bin/licensei bin/kind bin/kurun bin/helm bin/helm-docs +deps: ## Install dependencies + +# Dependency versions +GOLANGCI_VERSION = 1.53.3 +LICENSEI_VERSION = 0.8.0 +KIND_VERSION = 0.20.0 +KURUN_VERSION = 0.7.0 +HELM_DOCS_VERSION = 1.11.0 + +# Dependency binaries +GOLANGCI_LINT_BIN := golangci-lint +LICENSEI_BIN := licensei +KIND_BIN := kind +KURUN_BIN := kurun +HELM_BIN := helm +HELM_DOCS_BIN := helm-docs + +# TODO: add support for hadolint and yamllint dependencies +HADOLINT_BIN := hadolint +YAMLLINT_BIN := yamllint + +# If we have "bin" dir, use those binaries instead +ifneq ($(wildcard ./bin/.),) + GOLANGCI_LINT_BIN := bin/$(GOLANGCI_LINT_BIN) + LICENSEI_BIN := bin/$(LICENSEI_BIN) + KIND_BIN := bin/$(KIND_BIN) + KURUN_BIN := bin/$(KURUN_BIN) + HELM_BIN := bin/$(HELM_BIN) + HELM_DOCS_BIN := bin/$(HELM_DOCS_BIN) +endif + +bin/golangci-lint: + @mkdir -p bin + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | bash -s -- v${GOLANGCI_VERSION} + +bin/licensei: + @mkdir -p bin + curl -sfL https://raw.githubusercontent.com/goph/licensei/master/install.sh | bash -s -- v${LICENSEI_VERSION} + +bin/kind: + @mkdir -p bin + curl -Lo bin/kind https://kind.sigs.k8s.io/dl/v${KIND_VERSION}/kind-$(shell uname -s | tr '[:upper:]' '[:lower:]')-$(shell uname -m | sed -e "s/aarch64/arm64/; s/x86_64/amd64/") + @chmod +x bin/kind + +bin/kurun: + @mkdir -p bin + curl -Lo bin/kurun https://github.com/banzaicloud/kurun/releases/download/${KURUN_VERSION}/kurun-$(shell uname -s | tr '[:upper:]' '[:lower:]')-$(shell uname -m | sed -e "s/aarch64/arm64/; s/x86_64/amd64/") + @chmod +x bin/kurun + +bin/helm: + @mkdir -p bin + curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | USE_SUDO=false HELM_INSTALL_DIR=bin bash + @chmod +x bin/helm + +bin/helm-docs: + @mkdir -p bin + curl -L https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_$(shell uname)_x86_64.tar.gz | tar -zOxf - helm-docs > ./bin/helm-docs + @chmod +x bin/helm-docs diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..0c7a34d --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +Copyright © 2018 Banzai Cloud +Copyright © 2021 Cisco Systems, Inc. and/or its affiliates +Copyright © 2023 Bank-Vaults Maintainers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 90b75e5..815bce2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,109 @@ -# secrets-webhook -A Kubernetes mutating webhook that makes direct secret injection into Pods possible. +# Secrets Webhook + +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/bank-vaults/secrets-webhook/ci.yaml?style=flat-square)](https://github.com/bank-vaults/secrets-webhook/actions/workflows/ci.yaml) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/bank-vaults/secrets-webhook/badge?style=flat-square)](https://api.securityscorecards.dev/projects/github.com/bank-vaults/secrets-webhook) +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/7961/badge)](https://www.bestpractices.dev/projects/7961) +[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/secrets-webhook)](https://artifacthub.io/packages/search?repo=secrets-webhook) + +**A Kubernetes mutating webhook that makes direct secret injection into Pods possible.** + +## Documentation + +The official documentation for the webhook is available at [https://bank-vaults.dev](https://bank-vaults.dev/docs/mutating-webhook/). + +## Development + +**For an optimal developer experience, it is recommended to install [Nix](https://nixos.org/download.html) and [direnv](https://direnv.net/docs/installation.html).** + +_Alternatively, install [Go](https://go.dev/dl/) on your computer then run `make deps` to install the rest of the dependencies._ + +Make sure Docker is installed with Compose and Buildx. + +Fetch required tools: + +```shell +make deps +``` + +Run project dependencies: + +```shell +make up +``` + +Run the webhook: + +```shell +make -j run forward +``` + +Run the test suite: + +```shell +make test +make test-e2e-local +``` + +Run linters: + +```shell +make lint # pass -j option to run them in parallel +``` + +Some linter violations can automatically be fixed: + +```shell +make fmt +``` + +Build artifacts locally: + +```shell +make artifacts +``` + +Once you are done, you can tear down project dependencies: + +```shell +make down +``` + +### Running e2e tests + +The project comes with an e2e test suite that is mostly self-contained, +but at the very least, you need Docker installed. + +By default, the suite launches a [KinD](https://kind.sigs.k8s.io/) cluster, deploys all necessary components and runs the test suite. +This is a good option if you want to run the test suite to make sure everything works. This is also how the CI runs the test suite +(with a few minor differences). + +You can run the test suite by running the following commands: + +```shell +make test-e2e-local +``` + +Another way to run the test suite is using an existing cluster. +This may be a better option if you want to debug tests or figure out why something isn't working. + +Set up a Kubernetes cluster of your liking. For example, launch a KinD cluster: + +```shell +kind create cluster +``` + +Deploy the necessary components (including the webhook itself): + +```shell +garden deploy +``` + +Run the test suite: + +```shell +make BOOTSTRAP=false test-e2e +``` + +## License + +The project is licensed under the [Apache 2.0 License](LICENSE). diff --git a/deploy/charts/secrets-webhook/.helmignore b/deploy/charts/secrets-webhook/.helmignore new file mode 100644 index 0000000..f0c1319 --- /dev/null +++ b/deploy/charts/secrets-webhook/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/deploy/charts/secrets-webhook/Chart.yaml b/deploy/charts/secrets-webhook/Chart.yaml new file mode 100644 index 0000000..82a5ff2 --- /dev/null +++ b/deploy/charts/secrets-webhook/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +type: application +name: secrets-webhook +version: 0.0.0 +appVersion: latest +description: A Kubernetes mutating webhook that makes direct secret injection into Pods possible +icon: https://avatars.githubusercontent.com/u/129937617 +keywords: + - vault + - hashicorp + - secret + - webhook +home: https://bank-vaults.dev +sources: + - https://github.com/hashicorp/vault + - https://github.com/bank-vaults/secrets-webhook +maintainers: + - name: Bank Vaults Maintainers + email: team@bank-vaults.dev diff --git a/deploy/charts/secrets-webhook/README.md b/deploy/charts/secrets-webhook/README.md new file mode 100644 index 0000000..daee30a --- /dev/null +++ b/deploy/charts/secrets-webhook/README.md @@ -0,0 +1,225 @@ +# secrets-webhook + +A Kubernetes mutating webhook that makes direct secret injection into Pods possible + +**Homepage:** + +This chart will install a mutating admission webhook, that injects an executable to containers in Pods which than can request secrets from Vault through environment variable definitions. +It can also inject statically into ConfigMaps, Secrets, and CustomResources. + +## Using External Vault Instances + +You will need to add the following annotations to the resources that you wish to mutate: + +```yaml +vault.security.banzaicloud.io/vault-addr: https://[URL FOR VAULT] +vault.security.banzaicloud.io/vault-path: [Auth path] +vault.security.banzaicloud.io/vault-role: [Auth role] +vault.security.banzaicloud.io/vault-skip-verify: "true" # Container is missing Trusted Mozilla roots too. +``` + +Be mindful how you reference Vault secrets itself. For KV v2 secrets, you will need to add the `/data/` to the path of the secret. + +``` +$ vault kv get kv/rax/test +====== Metadata ====== +Key Value +--- ----- +created_time 2019-09-21T16:55:26.479739656Z +deletion_time n/a +destroyed false +version 1 + +=========== Data =========== +Key Value +--- ----- +MYSQL_PASSWORD 3xtr3ms3cr3t +MYSQL_ROOT_PASSWORD s3cr3t +``` + +The secret shown above is referenced like this: + +``` +vault:[ENGINE]/data/[SECRET_NAME]#[KEY] +vault:kv/rax/data/test#MYSQL_PASSWORD +``` + +If you want to use a specific key version, you can append it after the key so it becomes like this: + +`vault:kv/rax/data/test#MYSQL_PASSWORD#1` + +Omitting the version will tell Vault to pull the latest version. + +## Installing the Chart + +Before you install this chart you must create a namespace for it. This is due to the order in which the resources in the charts are applied (Helm collects all of the resources in a given Chart and its dependencies, groups them by resource type, and then installs them in a predefined order (see [here](https://github.com/helm/helm/blob/3547a4b5bf5edb5478ce352e18858d8a552a4110/pkg/releaseutil/kind_sorter.go#L31)). +The `MutatingWebhookConfiguration` gets created before the actual backend Pod which serves as the webhook itself, Kubernetes would like to mutate that pod as well, but it is not ready to mutate yet (infinite recursion in logic). + +### Prepare Kubernetes namespace + +In case of the K8s version is lower than 1.15 the namespace where you install the webhook must have a label of `name` with the namespace name as the label value, so the `namespaceSelector` in the `MutatingWebhookConfiguration` can skip the namespace of the webhook, so no self-mutation takes place. +If the K8s version is 1.15 at least, the default `objectSelector` will prevent the self-mutation (you don't have to configure anything) and you are free to install to any namespace of your choice. + +**You have to do this only in case you are using Helm < 3.2 and Kubernetes < 1.15.** + +```bash +WEBHOOK_NS=${WEBHOOK_NS:-vswh} +kubectl create namespace "${WEBHOOK_NS}" +kubectl label namespace "${WEBHOOK_NS}" name="${WEBHOOK_NS}" +``` + +### Install the chart + +```bash +$ helm install vswh --namespace vswh --wait oci://ghcr.io/bank-vaults/helm-charts/secrets-webhook --create-namespace +``` + +### Openshift 4.3 + +For security reasons, the `runAsUser` must be in the range between 1000570000 and 1000579999. By setting the value of `securityContext.runAsUser` to `""`, OpenShift chooses a valid User. + +```bash +$ helm upgrade --namespace vswh --install vswh oci://ghcr.io/bank-vaults/helm-charts/secrets-webhook --set-string securityContext.runAsUser="" --create-namespace +``` + +### About GKE Private Clusters + +When Google configures the control plane for private clusters, they automatically configure VPC peering between your Kubernetes cluster’s network in a separate Google managed project. + +The auto-generated rules **only** open ports 10250 and 443 between masters and nodes. This means that to use the webhook component with a GKE private cluster, you must configure an additional firewall rule to allow your masters CIDR to access your webhook pod using the port 8443. + +You can read more information on how to add firewall rules for the GKE control plane nodes in the [GKE docs](https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters#add_firewall_rules). + +## Values + +The following table lists the configurable parameters of the Helm chart. + +| Parameter | Type | Default | Description | +| --- | ---- | ------- | ----------- | +| `replicaCount` | int | `2` | Number of replicas | +| `debug` | bool | `false` | Enable debug logs for webhook | +| `certificate.useCertManager` | bool | `false` | Should request cert-manager for getting a new CA and TLS certificate | +| `certificate.servingCertificate` | string | `nil` | Should use an already externally defined Certificate by cert-manager | +| `certificate.generate` | bool | `true` | Should a new CA and TLS certificate be generated for the webhook | +| `certificate.server.tls.crt` | string | `""` | Base64 encoded TLS certificate signed by the CA | +| `certificate.server.tls.key` | string | `""` | Base64 encoded private key of TLS certificate signed by the CA | +| `certificate.ca.crt` | string | `""` | Base64 encoded CA certificate | +| `certificate.extraAltNames` | list | `[]` | Use extra names if you want to use the webhook via an ingress or a loadbalancer | +| `certificate.caLifespan` | int | `3650` | The number of days from the creation of the CA certificate until it expires | +| `certificate.certLifespan` | int | `365` | The number of days from the creation of the TLS certificate until it expires | +| `image.repository` | string | `"ghcr.io/bank-vaults/secrets-webhook"` | Container image repo that contains the admission server | +| `image.tag` | string | `""` | Container image tag | +| `image.pullPolicy` | string | `"IfNotPresent"` | Container image pull policy | +| `image.imagePullSecrets` | list | `[]` | Container image pull secrets for private repositories | +| `service.name` | string | `"secrets-webhook"` | Webhook service name | +| `service.type` | string | `"ClusterIP"` | Webhook service type | +| `service.externalPort` | int | `443` | Webhook service external port | +| `service.internalPort` | int | `8443` | Webhook service internal port | +| `service.annotations` | object | `{}` | Webhook service annotations, e.g. if type is AWS LoadBalancer and you want to add security groups | +| `ingress.enabled` | bool | `false` | Enable Webhook ingress | +| `ingress.annotations` | object | `{}` | Webhook ingress annotations | +| `ingress.host` | string | `""` | Webhook ingress host | +| `webhookClientConfig.useUrl` | bool | `false` | Use url if webhook should be contacted over loadbalancer or ingress instead of service object. By default, the mutating webhook uses the service of the webhook directly to contact webhook. | +| `webhookClientConfig.url` | string | `"https://example.com"` | Set the url how the webhook should be contacted, including the protocol | +| `secretInit.repository` | string | `"ghcr.io/bank-vaults/secret-init"` | Container image repo that contains the secret-init container | +| `secretInit.tag` | string | `"v0.1.0"` | Container image tag for the secret-init container | +| `env` | object | `{}` | Custom environment variables available to webhook | +| `initContainers` | list | `[]` | Containers to run before the webhook containers are started | +| `metrics.enabled` | bool | `false` | Enable metrics service for the webhook | +| `metrics.port` | int | `8443` | Metrics service port | +| `metrics.serviceMonitor.enabled` | bool | `false` | Enable service monitor | +| `metrics.serviceMonitor.scheme` | string | `"https"` | Service monitor scheme | +| `metrics.serviceMonitor.tlsConfig.insecureSkipVerify` | bool | `true` | Skip TLS checks for service monitor | +| `securityContext.runAsUser` | int | `65534` | Run containers in webhook deployment as specified user | +| `securityContext.allowPrivilegeEscalation` | bool | `false` | Allow process to gain more privileges than its parent process | +| `podSecurityContext` | object | `{}` | Pod security context for webhook deployment | +| `volumes` | list | `[]` | Extra volume definitions for webhook deployment | +| `volumeMounts` | list | `[]` | Extra volume mounts for webhook deployment | +| `podAnnotations` | object | `{}` | Extra annotations to add to pod metadata | +| `labels` | object | `{}` | Extra labels to add to the deployment and pods | +| `resources` | object | `{}` | Resources to request for the deployment and pods | +| `nodeSelector` | object | `{}` | Node labels for pod assignment. Check: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#nodeselector | +| `tolerations` | list | `[]` | List of node tolerations for the pods. Check: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/ | +| `affinity` | object | `{}` | Node affinity settings for the pods. Check: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ | +| `topologySpreadConstraints` | object | `{}` | TopologySpreadConstraints to add for the pods. Check: https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ | +| `priorityClassName` | string | `""` | Assign a PriorityClassName to pods if set. Check: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ | +| `livenessProbe` | object | `{"failureThreshold":3,"initialDelaySeconds":30,"periodSeconds":10,"successThreshold":1,"timeoutSeconds":1}` | Liveness and readiness probes for the webhook container | +| `readinessProbe.failureThreshold` | int | `3` | | +| `readinessProbe.periodSeconds` | int | `10` | | +| `readinessProbe.successThreshold` | int | `1` | | +| `readinessProbe.timeoutSeconds` | int | `1` | | +| `rbac.psp.enabled` | bool | `false` | Use pod security policy | +| `rbac.authDelegatorRole.enabled` | bool | `false` | Bind `system:auth-delegator` ClusterRoleBinding to given `serviceAccount` | +| `serviceAccount.create` | bool | `true` | Specifies whether a service account should be created | +| `serviceAccount.name` | string | `""` | The name of the service account to use. If not set and `create` is true, a name is generated using the fullname template. | +| `serviceAccount.labels` | object | `{}` | Labels to add to the service account | +| `serviceAccount.annotations` | object | `{}` | Annotations to add to the service account. For example, use `iam.gke.io/gcp-service-account: gsa@project.iam.gserviceaccount.com` to enable GKE workload identity. | +| `deployment.strategy` | object | `{}` | Rolling strategy for webhook deployment | +| `customResourceMutations` | list | `[]` | List of CustomResources to inject values from Vault, for example: ["ingresses", "servicemonitors"] | +| `customResourcesFailurePolicy` | string | `"Ignore"` | | +| `configMapMutation` | bool | `false` | Enable injecting values from Vault to ConfigMaps. This can cause issues when used with Helm, so it is disabled by default. | +| `secretsMutation` | bool | `true` | Enable injecting values from Vault to Secrets. Set to `false` in order to prevent secret values from being persisted in Kubernetes. | +| `configMapFailurePolicy` | string | `"Ignore"` | | +| `podsFailurePolicy` | string | `"Ignore"` | | +| `secretsFailurePolicy` | string | `"Ignore"` | | +| `apiSideEffectValue` | string | `"NoneOnDryRun"` | Webhook sideEffect value Check: https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#side-effects | +| `namespaceSelector` | object | `{}` | Namespace selector to use, will limit webhook scope (K8s version 1.15+) | +| `objectSelector` | object | `{}` | Object selector to use, will limit webhook scope (K8s version 1.15+) | +| `secrets.objectSelector` | object | `{}` | Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `secrets.namespaceSelector` | object | `{}` | Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `pods.objectSelector` | object | `{}` | Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `pods.namespaceSelector` | object | `{}` | Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `configMaps.objectSelector` | object | `{}` | Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `configMaps.namespaceSelector` | object | `{}` | Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `customResources.objectSelector` | object | `{}` | Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `customResources.namespaceSelector` | object | `{}` | Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ | +| `podDisruptionBudget.enabled` | bool | `true` | Enables PodDisruptionBudget | +| `podDisruptionBudget.minAvailable` | int | `1` | Represents the number of Pods that must be available (integer or percentage) | +| `timeoutSeconds` | bool | `false` | Webhook timeoutSeconds value | +| `hostNetwork` | bool | `false` | Allow pod to use the node network namespace | +| `dnsPolicy` | string | `""` | The dns policy desired for the deployment. If you're using cilium (CNI) and you are required to set hostNetwork to true, then pods with webhooks must set the dnsPolicy to "ClusterFirstWithHostNet" | +| `kubeVersion` | string | `""` | Override cluster version | + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. + +### Certificate options + +There are the following options for supplying the webhook with CA and TLS certificates. + +#### Generate (default) + +The default option is to let helm generate the CA and TLS certificates on deploy time. + +This will renew the certificates on each deployment. + +``` +certificate: + generate: true +``` + +#### Manually supplied + +Another option is to generate everything manually and specify the TLS `crt` and `key` plus the CA `crt` as values. +These values need to be base64 encoded x509 certificates. + +```yaml +certificate: + generate: false + server: + tls: + crt: LS0tLS1... + key: LS0tLS1... + ca: + crt: LS0tLS1... +``` + +#### Using cert-manager + +If you use cert-manager in your cluster, you can instruct cert-manager to manage everything. +The following options will let cert-manager generate TLS `certificate` and `key` plus the CA `certificate`. + +```yaml +certificate: + generate: false + useCertManager: true +``` diff --git a/deploy/charts/secrets-webhook/README.md.gotmpl b/deploy/charts/secrets-webhook/README.md.gotmpl new file mode 100644 index 0000000..4b23118 --- /dev/null +++ b/deploy/charts/secrets-webhook/README.md.gotmpl @@ -0,0 +1,149 @@ +{{ template "chart.header" . }} + +{{ template "chart.description" . }} + +{{ template "chart.homepageLine" . }} + +This chart will install a mutating admission webhook, that injects an executable to containers in Pods which than can request secrets from Vault through environment variable definitions. +It can also inject statically into ConfigMaps, Secrets, and CustomResources. + +## Using External Vault Instances + +You will need to add the following annotations to the resources that you wish to mutate: + +```yaml +vault.security.banzaicloud.io/vault-addr: https://[URL FOR VAULT] +vault.security.banzaicloud.io/vault-path: [Auth path] +vault.security.banzaicloud.io/vault-role: [Auth role] +vault.security.banzaicloud.io/vault-skip-verify: "true" # Container is missing Trusted Mozilla roots too. +``` + +Be mindful how you reference Vault secrets itself. For KV v2 secrets, you will need to add the `/data/` to the path of the secret. + +``` +$ vault kv get kv/rax/test +====== Metadata ====== +Key Value +--- ----- +created_time 2019-09-21T16:55:26.479739656Z +deletion_time n/a +destroyed false +version 1 + +=========== Data =========== +Key Value +--- ----- +MYSQL_PASSWORD 3xtr3ms3cr3t +MYSQL_ROOT_PASSWORD s3cr3t +``` + +The secret shown above is referenced like this: + +``` +vault:[ENGINE]/data/[SECRET_NAME]#[KEY] +vault:kv/rax/data/test#MYSQL_PASSWORD +``` + +If you want to use a specific key version, you can append it after the key so it becomes like this: + +`vault:kv/rax/data/test#MYSQL_PASSWORD#1` + +Omitting the version will tell Vault to pull the latest version. + +## Installing the Chart + +Before you install this chart you must create a namespace for it. This is due to the order in which the resources in the charts are applied (Helm collects all of the resources in a given Chart and its dependencies, groups them by resource type, and then installs them in a predefined order (see [here](https://github.com/helm/helm/blob/3547a4b5bf5edb5478ce352e18858d8a552a4110/pkg/releaseutil/kind_sorter.go#L31)). +The `MutatingWebhookConfiguration` gets created before the actual backend Pod which serves as the webhook itself, Kubernetes would like to mutate that pod as well, but it is not ready to mutate yet (infinite recursion in logic). + +### Prepare Kubernetes namespace + +In case of the K8s version is lower than 1.15 the namespace where you install the webhook must have a label of `name` with the namespace name as the label value, so the `namespaceSelector` in the `MutatingWebhookConfiguration` can skip the namespace of the webhook, so no self-mutation takes place. +If the K8s version is 1.15 at least, the default `objectSelector` will prevent the self-mutation (you don't have to configure anything) and you are free to install to any namespace of your choice. + +**You have to do this only in case you are using Helm < 3.2 and Kubernetes < 1.15.** + +```bash +WEBHOOK_NS=${WEBHOOK_NS:-vswh} +kubectl create namespace "${WEBHOOK_NS}" +kubectl label namespace "${WEBHOOK_NS}" name="${WEBHOOK_NS}" +``` + +### Install the chart + +```bash +$ helm install vswh --namespace vswh --wait oci://ghcr.io/bank-vaults/helm-charts/secrets-webhook --create-namespace +``` + +### Openshift 4.3 + +For security reasons, the `runAsUser` must be in the range between 1000570000 and 1000579999. By setting the value of `securityContext.runAsUser` to `""`, OpenShift chooses a valid User. + +```bash +$ helm upgrade --namespace vswh --install vswh oci://ghcr.io/bank-vaults/helm-charts/secrets-webhook --set-string securityContext.runAsUser="" --create-namespace +``` + +### About GKE Private Clusters + +When Google configures the control plane for private clusters, they automatically configure VPC peering between your Kubernetes cluster’s network in a separate Google managed project. + +The auto-generated rules **only** open ports 10250 and 443 between masters and nodes. This means that to use the webhook component with a GKE private cluster, you must configure an additional firewall rule to allow your masters CIDR to access your webhook pod using the port 8443. + +You can read more information on how to add firewall rules for the GKE control plane nodes in the [GKE docs](https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters#add_firewall_rules). + +{{ define "chart.valuesTableHtml" }} + +The following table lists the configurable parameters of the Helm chart. + +| Parameter | Type | Default | Description | +| --- | ---- | ------- | ----------- | +{{- range .Values }} +| `{{ .Key }}` | {{ .Type }} | {{ .Default }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | +{{- end }} + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. + +{{ end }} + +{{ template "chart.valuesSectionHtml" . }} + +### Certificate options + +There are the following options for supplying the webhook with CA and TLS certificates. + +#### Generate (default) + +The default option is to let helm generate the CA and TLS certificates on deploy time. + +This will renew the certificates on each deployment. + +``` +certificate: + generate: true +``` + +#### Manually supplied + +Another option is to generate everything manually and specify the TLS `crt` and `key` plus the CA `crt` as values. +These values need to be base64 encoded x509 certificates. + +```yaml +certificate: + generate: false + server: + tls: + crt: LS0tLS1... + key: LS0tLS1... + ca: + crt: LS0tLS1... +``` + +#### Using cert-manager + +If you use cert-manager in your cluster, you can instruct cert-manager to manage everything. +The following options will let cert-manager generate TLS `certificate` and `key` plus the CA `certificate`. + +```yaml +certificate: + generate: false + useCertManager: true +``` diff --git a/deploy/charts/secrets-webhook/templates/_helpers.tpl b/deploy/charts/secrets-webhook/templates/_helpers.tpl new file mode 100644 index 0000000..75b125d --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/_helpers.tpl @@ -0,0 +1,111 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "secrets-webhook.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "secrets-webhook.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "secrets-webhook.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "secrets-webhook.selfSignedIssuer" -}} +{{ printf "%s-selfsign" (include "secrets-webhook.fullname" .) }} +{{- end -}} + +{{- define "secrets-webhook.rootCAIssuer" -}} +{{ printf "%s-ca" (include "secrets-webhook.fullname" .) }} +{{- end -}} + +{{- define "secrets-webhook.rootCACertificate" -}} +{{ printf "%s-ca" (include "secrets-webhook.fullname" .) }} +{{- end -}} + +{{- define "secrets-webhook.servingCertificate" -}} +{{- if .Values.certificate.servingCertificate -}} +{{ .Values.certificate.servingCertificate }} +{{- else -}} +{{ printf "%s-webhook-tls" (include "secrets-webhook.fullname" .) }} +{{- end -}} +{{- end -}} + +{{/* +Overrideable version for container image tags. +*/}} +{{- define "secrets-webhook.bank-vaults.version" -}} +{{- .Values.image.tag | default (printf "%s" .Chart.AppVersion) -}} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "secrets-webhook.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "secrets-webhook.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Return the target Kubernetes version. +https://github.com/bitnami/charts/blob/master/bitnami/common/templates/_capabilities.tpl +*/}} +{{- define "secrets-webhook.capabilities.kubeVersion" -}} +{{- default .Capabilities.KubeVersion.Version .Values.kubeVersion -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for policy. +*/}} +{{- define "secrets-webhook.capabilities.policy.apiVersion" -}} +{{- if semverCompare "<1.21-0" (include "secrets-webhook.capabilities.kubeVersion" .) -}} +{{- print "policy/v1beta1" -}} +{{- else -}} +{{- print "policy/v1" -}} +{{- end -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for ingress. +*/}} +{{- define "secrets-webhook.capabilities.ingress.apiVersion" -}} +{{- if .Values.ingress -}} +{{- if .Values.ingress.apiVersion -}} +{{- .Values.ingress.apiVersion -}} +{{- else if semverCompare "<1.14-0" (include "secrets-webhook.capabilities.kubeVersion" .) -}} +{{- print "extensions/v1beta1" -}} +{{- else if semverCompare "<1.19-0" (include "secrets-webhook.capabilities.kubeVersion" .) -}} +{{- print "networking.k8s.io/v1beta1" -}} +{{- else -}} +{{- print "networking.k8s.io/v1" -}} +{{- end }} +{{- else if semverCompare "<1.14-0" (include "secrets-webhook.capabilities.kubeVersion" .) -}} +{{- print "extensions/v1beta1" -}} +{{- else if semverCompare "<1.19-0" (include "secrets-webhook.capabilities.kubeVersion" .) -}} +{{- print "networking.k8s.io/v1beta1" -}} +{{- else -}} +{{- print "networking.k8s.io/v1" -}} +{{- end -}} +{{- end -}} diff --git a/deploy/charts/secrets-webhook/templates/apiservice-webhook.yaml b/deploy/charts/secrets-webhook/templates/apiservice-webhook.yaml new file mode 100644 index 0000000..74ca604 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/apiservice-webhook.yaml @@ -0,0 +1,375 @@ +{{- $tlsCrt := "" }} +{{- $tlsKey := "" }} +{{- $caCrt := "" }} +{{- if .Values.certificate.generate }} +{{- $ca := genCA "svc-cat-ca" (.Values.certificate.caLifespan | int) }} +{{- $svcName := include "secrets-webhook.fullname" . }} +{{- $cn := printf "%s.%s.svc" $svcName .Release.Namespace }} +{{- $altName1 := printf "%s.cluster.local" $cn }} +{{- $altName2 := printf "%s" $cn }} +{{- $server := genSignedCert $cn nil (concat (list $altName1 $altName2) .Values.certificate.extraAltNames) (.Values.certificate.certLifespan | int) $ca }} +{{- $tlsCrt = b64enc $server.Cert }} +{{- $tlsKey = b64enc $server.Key }} +{{- $caCrt = b64enc $ca.Cert }} +{{- else if .Values.certificate.useCertManager }} +{{/* Create a new Certificate with cert-manager. */}} +{{/* all clientConfig.caBundle will be overridden by cert-manager */}} +{{- else if .Values.certificate.servingCertificate }} +{{/* Use an already externally defined Certificate by cert-manager. */}} +{{/* all clientConfig.caBundle will be overridden by cert-manager */}} +{{- else }} +{{- $tlsCrt = required "Value certificate.server.tls.crt is required when certificate.generate is false" .Values.certificate.server.tls.crt }} +{{- $tlsKey = required "Value certificate.server.tls.key is required when certificate.generate is false" .Values.certificate.server.tls.key }} +{{- $caCrt = required "Value certificate.ca.crt is required when certificate.generate is false" .Values.certificate.ca.crt }} +{{- end }} + + +{{- $secretsNamespaceSelector := default dict }} +{{- $secretsObjectSelector := default dict }} +{{- $configmapsNamespaceSelector := default dict }} +{{- $configmapsObjectSelector := default dict }} +{{- $podsNamespaceSelector := default dict }} +{{- $podsObjectSelector := default dict }} +{{- $crNamespaceSelector := default dict }} +{{- $crObjectSelector := default dict }} + +{{- if .Values.secrets.namespaceSelector }} +{{- $secretsNamespaceSelector = .Values.secrets.namespaceSelector }} +{{- else }} +{{- $secretsNamespaceSelector = .Values.namespaceSelector }} +{{- end }} +{{- if .Values.secrets.objectSelector }} +{{- $secretsObjectSelector = .Values.secrets.objectSelector }} +{{- else }} +{{- $secretsObjectSelector = .Values.objectSelector }} +{{- end }} + +{{- if .Values.configMaps.namespaceSelector }} +{{- $configmapsNamespaceSelector = .Values.configMaps.namespaceSelector }} +{{- else }} +{{- $configmapsNamespaceSelector = .Values.namespaceSelector }} +{{- end }} +{{- if .Values.configMaps.objectSelector }} +{{- $configmapsObjectSelector = .Values.configMaps.objectSelector }} +{{- else }} +{{- $configmapsObjectSelector = .Values.objectSelector }} +{{- end }} + +{{- if .Values.pods.namespaceSelector }} +{{- $podsNamespaceSelector = .Values.pods.namespaceSelector }} +{{- else }} +{{- $podsNamespaceSelector = .Values.namespaceSelector }} +{{- end }} +{{- if .Values.pods.objectSelector }} +{{- $podsObjectSelector = .Values.pods.objectSelector }} +{{- else }} +{{- $podsObjectSelector = .Values.objectSelector }} +{{- end }} + +{{- if .Values.customResources.namespaceSelector }} +{{- $crNamespaceSelector = .Values.customResources.namespaceSelector }} +{{- else }} +{{- $crNamespaceSelector = .Values.namespaceSelector }} +{{- end }} +{{- if .Values.customResources.objectSelector }} +{{- $crObjectSelector = .Values.customResources.objectSelector }} +{{- else }} +{{- $crObjectSelector = .Values.objectSelector }} +{{- end }} + + +{{- if $tlsCrt }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "secrets-webhook.servingCertificate" . }} + namespace: {{ .Release.Namespace }} +data: + tls.crt: {{ $tlsCrt }} + tls.key: {{ $tlsKey }} + ca.crt: {{ $caCrt }} +{{- end }} +--- +{{- if semverCompare ">=1.16-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} +apiVersion: admissionregistration.k8s.io/v1 +{{- else }} +apiVersion: admissionregistration.k8s.io/v1beta1 +{{- end }} +kind: MutatingWebhookConfiguration +metadata: + name: {{ template "secrets-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} +{{- if .Values.certificate.useCertManager }} + annotations: + cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/{{ include "secrets-webhook.servingCertificate" . }}" +{{- else if .Values.certificate.servingCertificate }} + annotations: + cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/{{ .Values.certificate.servingCertificate }}" +{{- end }} +webhooks: +- name: pods.{{ template "secrets-webhook.name" . }}.admission.banzaicloud.com + {{- if semverCompare ">=1.14-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + {{- with .Values.reinvocationPolicy }} + reinvocationPolicy: {{ . }} + {{- end }} + admissionReviewVersions: ["v1beta1"] + {{- if .Values.timeoutSeconds }} + timeoutSeconds: {{ .Values.timeoutSeconds }} + {{- end }} + {{- end }} + clientConfig: + {{- if .Values.webhookClientConfig.useUrl }} + url: {{ .Values.webhookClientConfig.url }} + {{- else }} + service: + namespace: {{ .Release.Namespace }} + name: {{ template "secrets-webhook.fullname" . }} + path: /pods + {{- end }} + {{- if not .Values.certificate.useCertManager }} + caBundle: {{ $caCrt }} + {{- end }} + rules: + - operations: + - CREATE + apiGroups: + - "*" + apiVersions: + - "*" + resources: + - pods + failurePolicy: {{ .Values.podsFailurePolicy }} + namespaceSelector: + {{- if $podsNamespaceSelector.matchLabels }} + matchLabels: +{{ toYaml $podsNamespaceSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $podsNamespaceSelector.matchExpressions }} +{{ toYaml $podsNamespaceSelector.matchExpressions | indent 4 }} + {{- end }} + - key: kubernetes.io/metadata.name + operator: NotIn + values: + - {{ .Release.Namespace }} +{{- if semverCompare ">=1.15-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + objectSelector: + {{- if $podsObjectSelector.matchLabels }} + matchLabels: +{{ toYaml $podsObjectSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $podsObjectSelector.matchExpressions }} +{{ toYaml $podsObjectSelector.matchExpressions | indent 4 }} + {{- end }} + - key: security.banzaicloud.io/mutate + operator: NotIn + values: + - skip +{{- end }} +{{- if semverCompare ">=1.12-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + sideEffects: {{ .Values.apiSideEffectValue }} +{{- end }} +{{- if .Values.secretsMutation }} +- name: secrets.{{ template "secrets-webhook.name" . }}.admission.banzaicloud.com + {{- with .Values.reinvocationPolicy }} + reinvocationPolicy: {{ . }} + {{- end }} + {{- if semverCompare ">=1.14-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + admissionReviewVersions: ["v1beta1"] + {{- if .Values.timeoutSeconds }} + timeoutSeconds: {{ .Values.timeoutSeconds }} + {{- end }} + {{- end }} + clientConfig: + {{- if .Values.webhookClientConfig.useUrl }} + url: {{ .Values.webhookClientConfig.url }} + {{- else }} + service: + namespace: {{ .Release.Namespace }} + name: {{ template "secrets-webhook.fullname" . }} + path: /secrets + {{- end }} + {{- if not .Values.certificate.useCertManager }} + caBundle: {{ $caCrt }} + {{- end }} + rules: + - operations: + - CREATE + - UPDATE + apiGroups: + - "*" + apiVersions: + - "*" + resources: + - secrets + failurePolicy: {{ .Values.secretsFailurePolicy }} + namespaceSelector: + {{- if $secretsNamespaceSelector.matchLabels }} + matchLabels: +{{ toYaml $secretsNamespaceSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $secretsNamespaceSelector.matchExpressions }} +{{ toYaml $secretsNamespaceSelector.matchExpressions | indent 4 }} + {{- end }} + - key: kubernetes.io/metadata.name + operator: NotIn + values: + - {{ .Release.Namespace }} +{{- if semverCompare ">=1.15-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + objectSelector: + {{- if $secretsObjectSelector.matchLabels }} + matchLabels: +{{ toYaml $secretsObjectSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $secretsObjectSelector.matchExpressions }} +{{ toYaml $secretsObjectSelector.matchExpressions | indent 4 }} + {{- end }} + - key: owner + operator: NotIn + values: + - helm + - key: security.banzaicloud.io/mutate + operator: NotIn + values: + - skip +{{- end }} +{{- if semverCompare ">=1.12-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + sideEffects: {{ .Values.apiSideEffectValue }} +{{- end }} +{{- end }} +{{- if .Values.configMapMutation }} +- name: configmaps.{{ template "secrets-webhook.name" . }}.admission.banzaicloud.com + {{- if semverCompare ">=1.14-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + admissionReviewVersions: ["v1beta1"] + {{- with .Values.reinvocationPolicy }} + reinvocationPolicy: {{ . }} + {{- end }} + {{- if .Values.timeoutSeconds }} + timeoutSeconds: {{ .Values.timeoutSeconds }} + {{- end }} + {{- end }} + clientConfig: + {{- if .Values.webhookClientConfig.useUrl }} + url: {{ .Values.webhookClientConfig.url }} + {{- else }} + service: + namespace: {{ .Release.Namespace }} + name: {{ template "secrets-webhook.fullname" . }} + path: /configmaps + {{- end }} + {{- if not .Values.certificate.useCertManager }} + caBundle: {{ $caCrt }} + {{- end }} + rules: + - operations: + - CREATE + - UPDATE + apiGroups: + - "*" + apiVersions: + - "*" + resources: + - configmaps + failurePolicy: {{ .Values.configmapFailurePolicy | default .Values.configMapFailurePolicy }} + namespaceSelector: + {{- if $configmapsNamespaceSelector.matchLabels }} + matchLabels: +{{ toYaml $configmapsNamespaceSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $configmapsNamespaceSelector.matchExpressions }} +{{ toYaml $configmapsNamespaceSelector.matchExpressions | indent 4 }} + {{- end }} + - key: kubernetes.io/metadata.name + operator: NotIn + values: + - {{ .Release.Namespace }} +{{- if semverCompare ">=1.15-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + objectSelector: + {{- if $configmapsObjectSelector.matchLabels }} + matchLabels: +{{ toYaml $configmapsObjectSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $configmapsObjectSelector.matchExpressions }} +{{ toYaml $configmapsObjectSelector.matchExpressions | indent 4 }} + {{- end }} + - key: owner + operator: NotIn + values: + - helm + - key: security.banzaicloud.io/mutate + operator: NotIn + values: + - skip +{{- end }} +{{- if semverCompare ">=1.12-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + sideEffects: {{ .Values.apiSideEffectValue }} +{{- end }} +{{- end }} +{{- if .Values.customResourceMutations }} +- name: objects.{{ template "secrets-webhook.name" . }}.admission.banzaicloud.com + {{- if semverCompare ">=1.14-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + admissionReviewVersions: ["v1beta1"] + {{- if .Values.timeoutSeconds }} + timeoutSeconds: {{ .Values.timeoutSeconds }} + {{- end }} + {{- end }} + clientConfig: + {{- if .Values.webhookClientConfig.useUrl }} + url: {{ .Values.webhookClientConfig.url }} + {{- else }} + service: + namespace: {{ .Release.Namespace }} + name: {{ template "secrets-webhook.fullname" . }} + path: /objects + {{- end }} + {{- if not .Values.certificate.useCertManager }} + caBundle: {{ $caCrt }} + {{- end }} + rules: + - operations: + - CREATE + - UPDATE + apiGroups: + - "*" + apiVersions: + - "*" + resources: +{{ toYaml .Values.customResourceMutations | indent 6 }} + failurePolicy: {{ .Values.customResourcesFailurePolicy }} + namespaceSelector: + {{- if $crNamespaceSelector.matchLabels }} + matchLabels: +{{ toYaml $crNamespaceSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $crNamespaceSelector.matchExpressions }} +{{ toYaml $crNamespaceSelector.matchExpressions | indent 4 }} + {{- end }} + - key: kubernetes.io/metadata.name + operator: NotIn + values: + - {{ .Release.Namespace }} +{{- if semverCompare ">=1.15-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + objectSelector: + {{- if $crObjectSelector.matchLabels }} + matchLabels: +{{ toYaml $crObjectSelector.matchLabels | indent 6 }} + {{- end }} + matchExpressions: + {{- if $crObjectSelector.matchExpressions }} +{{ toYaml $crObjectSelector.matchExpressions | indent 4 }} + {{- end }} + - key: security.banzaicloud.io/mutate + operator: NotIn + values: + - skip +{{- end }} +{{- if semverCompare ">=1.12-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} + sideEffects: {{ .Values.apiSideEffectValue }} +{{- end }} +{{- end }} diff --git a/deploy/charts/secrets-webhook/templates/prometheus-monitorservice.yaml b/deploy/charts/secrets-webhook/templates/prometheus-monitorservice.yaml new file mode 100644 index 0000000..e2e5602 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/prometheus-monitorservice.yaml @@ -0,0 +1,61 @@ +{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ template "secrets-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + helm.sh/chart: {{ template "secrets-webhook.chart" . }} + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: mutating-webhook + {{- if .Values.metrics.serviceMonitor.additionalLabels }} +{{ toYaml .Values.metrics.serviceMonitor.additionalLabels | indent 4 }} + {{- end }} +spec: + endpoints: + - interval: 30s + port: metrics + scheme: {{ .Values.metrics.serviceMonitor.scheme }} + {{- if .Values.metrics.serviceMonitor.relabellings }} + metricrelabelings: +{{ toYaml .Values.metrics.serviceMonitor.relabellings | indent 6 }} + {{- end }} + {{- if .Values.metrics.serviceMonitor.tlsConfig }} + tlsConfig: +{{ toYaml .Values.metrics.serviceMonitor.tlsConfig | indent 6 }} + {{- end }} + jobLabel: {{ template "secrets-webhook.name" . }} + selector: + matchLabels: + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: "{{ .Release.Name }}" +{{- end }} +{{- if .Values.metrics.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + name: {{ template "secrets-webhook.fullname" . }}-metrics + namespace: {{ .Release.Namespace }} + labels: + helm.sh/chart: {{ template "secrets-webhook.chart" . }} + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: mutating-webhook +spec: + clusterIP: None + ports: + - name: metrics + port: {{ .Values.metrics.port }} + protocol: TCP + targetPort: {{ .Values.metrics.port }} + selector: + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: "{{ .Release.Name }}" + sessionAffinity: None + type: ClusterIP +{{- end }} diff --git a/deploy/charts/secrets-webhook/templates/warnings.tpl b/deploy/charts/secrets-webhook/templates/warnings.tpl new file mode 100644 index 0000000..490c3be --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/warnings.tpl @@ -0,0 +1,13 @@ +{{/* this file is for generating warnings about incorrect usage of the chart */}} + +{{- if .Values.certificate.generate }} +{{- if .Values.certificate.useCertManager }} + {{ fail "It is not allowed to both set certificate.generate=true and certificate.useCertManager=true."}} +{{- end }} +{{- end }} + +{{- if .Values.webhookClientConfig.useUrl -}} +{{- if or (not .Values.webhookClientConfig.url ) }} + {{ fail "When webhookClientConfig.useUrl=true webhookClientConfig.url should be set and not empty "}} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/charts/secrets-webhook/templates/webhook-cert-manager.yaml b/deploy/charts/secrets-webhook/templates/webhook-cert-manager.yaml new file mode 100644 index 0000000..dae5ca7 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/webhook-cert-manager.yaml @@ -0,0 +1,86 @@ +{{- if .Values.certificate.useCertManager }} +--- +# Create a selfsigned Issuer, in order to create a root CA certificate for +# signing webhook serving certificates +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ include "secrets-webhook.selfSignedIssuer" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + app: {{ include "secrets-webhook.name" . }} + chart: {{ include "secrets-webhook.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + selfSigned: {} + +--- + +# Generate a CA Certificate used to sign certificates for the webhook +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "secrets-webhook.rootCACertificate" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + app: {{ include "secrets-webhook.name" . }} + chart: {{ include "secrets-webhook.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + secretName: {{ include "secrets-webhook.rootCACertificate" . }} + duration: 43800h0m0s # 5y + issuerRef: + name: {{ include "secrets-webhook.selfSignedIssuer" . }} + commonName: "ca.secrets-webhook.cert-manager" + isCA: true + privateKey: + rotationPolicy: Always +--- + +# Create an Issuer that uses the above generated CA certificate to issue certs +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ include "secrets-webhook.rootCAIssuer" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + app: {{ include "secrets-webhook.name" . }} + chart: {{ include "secrets-webhook.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + ca: + secretName: {{ include "secrets-webhook.rootCACertificate" . }} + +{{- end }} +{{- if or .Values.certificate.useCertManager .Values.certificate.servingCertificate }} +--- + +# Finally, generate a serving certificate for the webhook to use +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "secrets-webhook.servingCertificate" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + app: {{ include "secrets-webhook.name" . }} + chart: {{ include "secrets-webhook.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + secretName: {{ include "secrets-webhook.servingCertificate" . }} + duration: 8760h0m0s # 1y + issuerRef: + name: {{ include "secrets-webhook.rootCAIssuer" . }} + dnsNames: + - {{ include "secrets-webhook.fullname" . }} + - {{ include "secrets-webhook.fullname" . }}.{{ .Release.Namespace }} + - {{ include "secrets-webhook.fullname" . }}.{{ .Release.Namespace }}.svc + {{- range .Values.certificate.extraAltNames }} + - {{ . }} + {{- end }} + privateKey: + rotationPolicy: Always +{{- end }} diff --git a/deploy/charts/secrets-webhook/templates/webhook-deployment.yaml b/deploy/charts/secrets-webhook/templates/webhook-deployment.yaml new file mode 100644 index 0000000..233e1b3 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/webhook-deployment.yaml @@ -0,0 +1,138 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "secrets-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + helm.sh/chart: {{ template "secrets-webhook.chart" . }} + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: mutating-webhook + {{- if .Values.labels }} +{{ toYaml .Values.labels | indent 4 }} + {{- end }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + {{- if .Values.deployment }} + {{- if .Values.deployment.strategy }} + strategy: +{{ toYaml .Values.deployment.strategy | indent 4 }} + {{- end }} + {{- end }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + security.banzaicloud.io/mutate: skip + {{- if .Values.labels }} +{{ toYaml .Values.labels | indent 8 }} + {{- end }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/apiservice-webhook.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.hostNetwork}} + hostNetwork: {{ .Values.hostNetwork}} + {{- end }} + {{- with .Values.dnsPolicy }} + dnsPolicy: {{ . }} + {{- end }} + serviceAccountName: {{ template "secrets-webhook.serviceAccountName" . }} + {{- if .Values.priorityClassName }} + priorityClassName: {{ .Values.priorityClassName }} + {{- end }} + volumes: + - name: serving-cert + secret: + defaultMode: 420 + secretName: {{ include "secrets-webhook.servingCertificate" . }} +{{- if .Values.volumes }} +{{ toYaml .Values.volumes | indent 8 }} +{{- end }} + {{- if .Values.image.imagePullSecrets }} + imagePullSecrets: +{{ toYaml .Values.image.imagePullSecrets | indent 8 }} + {{- end }} +{{- if .Values.initContainers }} + initContainers: +{{ toYaml .Values.initContainers | indent 8}} +{{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ include "secrets-webhook.bank-vaults.version" . }}" + env: + - name: TLS_CERT_FILE + value: /var/serving-cert/tls.crt + - name: TLS_PRIVATE_KEY_FILE + value: /var/serving-cert/tls.key + - name: LISTEN_ADDRESS + value: ":{{ .Values.service.internalPort }}" + {{- if .Values.debug }} + - name: LOG_LEVEL + value: "debug" + {{- end }} + - name: SECRET_INIT_IMAGE + value: "{{ .Values.secretInit.repository }}:{{ .Values.secretInit.tag }}" + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.internalPort }} + livenessProbe: + httpGet: + scheme: HTTPS + path: /healthz + port: {{ .Values.service.internalPort }} + initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} + failureThreshold: {{ .Values.livenessProbe.failureThreshold }} + periodSeconds: {{ .Values.livenessProbe.periodSeconds }} + successThreshold: {{ .Values.livenessProbe.successThreshold }} + timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }} + readinessProbe: + httpGet: + scheme: HTTPS + path: /healthz + port: {{ .Values.service.internalPort }} + failureThreshold: {{ .Values.readinessProbe.failureThreshold }} + periodSeconds: {{ .Values.readinessProbe.periodSeconds }} + successThreshold: {{ .Values.readinessProbe.successThreshold }} + timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }} + volumeMounts: + - mountPath: /var/serving-cert + name: serving-cert +{{- if .Values.volumeMounts }} +{{ toYaml .Values.volumeMounts | indent 12 }} +{{- end }} + securityContext: {{- toYaml .Values.securityContext | nindent 12 }} + resources: +{{ toYaml .Values.resources | indent 12 }} + {{- if .Values.nodeSelector }} + nodeSelector: +{{ toYaml .Values.nodeSelector | indent 8 }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: +{{ toYaml .Values.tolerations | indent 8 }} + {{- end }} + {{- if .Values.affinity }} + affinity: +{{ toYaml .Values.affinity | indent 8 }} + {{- end }} + {{- if .Values.podSecurityContext }} + securityContext: +{{ toYaml .Values.podSecurityContext | indent 8 }} + {{- end }} + {{- if .Values.topologySpreadConstraints }} + topologySpreadConstraints: +{{ toYaml .Values.topologySpreadConstraints | indent 8 }} + {{- end }} diff --git a/deploy/charts/secrets-webhook/templates/webhook-ingress.yaml b/deploy/charts/secrets-webhook/templates/webhook-ingress.yaml new file mode 100644 index 0000000..d737036 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/webhook-ingress.yaml @@ -0,0 +1,28 @@ +{{- if .Values.ingress.enabled }} +--- +apiVersion: {{ include "secrets-webhook.capabilities.ingress.apiVersion" . }} +kind: Ingress +metadata: + name: {{ template "secrets-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} + {{- if .Values.ingress.annotations }} + annotations: +{{ toYaml .Values.ingress.annotations | indent 4 }} + {{- end }} +spec: + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ include "secrets-webhook.servingCertificate" . }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: ImplementationSpecific + backend: + service: + name: {{ template "secrets-webhook.fullname" . }} + port: + number: {{ .Values.service.externalPort }} +{{- end }} diff --git a/deploy/charts/secrets-webhook/templates/webhook-pdb.yaml b/deploy/charts/secrets-webhook/templates/webhook-pdb.yaml new file mode 100644 index 0000000..1682816 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/webhook-pdb.yaml @@ -0,0 +1,24 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: {{ include "secrets-webhook.capabilities.policy.apiVersion" . }} +kind: PodDisruptionBudget +metadata: + name: {{ template "secrets-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + helm.sh/chart: {{ template "secrets-webhook.chart" . }} + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: mutating-webhook +spec: + {{- with .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ . }} + {{- end }} + {{- with .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ . }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/charts/secrets-webhook/templates/webhook-psp.yaml b/deploy/charts/secrets-webhook/templates/webhook-psp.yaml new file mode 100644 index 0000000..e99be60 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/webhook-psp.yaml @@ -0,0 +1,65 @@ +{{- if .Values.rbac.psp.enabled }} +{{- if semverCompare ">=1.16-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} +apiVersion: policy/v1beta1 +{{- else }} +apiVersion: extensions/v1beta1 +{{- end }} +kind: PodSecurityPolicy +metadata: + name: {{ template "secrets-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} + annotations: + seccomp.security.alpha.kubernetes.io/allowedProfileNames: docker/default + seccomp.security.alpha.kubernetes.io/defaultProfileName: docker/default +spec: + allowPrivilegeEscalation: false + allowedCapabilities: + - IPC_LOCK + fsGroup: + ranges: + - max: 65535 + min: 1 + rule: MustRunAs + readOnlyRootFilesystem: true + runAsUser: + rule: MustRunAsNonRoot + seLinux: + rule: RunAsAny + supplementalGroups: + ranges: + - max: 65535 + min: 1 + rule: MustRunAs + volumes: + - secret + - emptyDir + - configMap +--- +{{- if semverCompare ">=1.16-0" (include "secrets-webhook.capabilities.kubeVersion" .) }} +apiVersion: policy/v1beta1 +{{- else }} +apiVersion: extensions/v1beta1 +{{- end }} +kind: PodSecurityPolicy +metadata: + annotations: + seccomp.security.alpha.kubernetes.io/allowedProfileNames: docker/default + seccomp.security.alpha.kubernetes.io/defaultProfileName: docker/default + name: {{ template "secrets-webhook.fullname" . }}.mutate + namespace: {{ .Release.Namespace }} +spec: + allowPrivilegeEscalation: false + fsGroup: + rule: RunAsAny + runAsUser: + rule: RunAsAny + seLinux: + rule: RunAsAny + supplementalGroups: + rule: RunAsAny + volumes: + - secret + - downwardAPI + - emptyDir + - configMap +{{- end }} diff --git a/deploy/charts/secrets-webhook/templates/webhook-rbac.yaml b/deploy/charts/secrets-webhook/templates/webhook-rbac.yaml new file mode 100644 index 0000000..1133bfa --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/webhook-rbac.yaml @@ -0,0 +1,93 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ template "secrets-webhook.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + helm.sh/chart: {{ include "secrets-webhook.chart" . }} + app.kubernetes.io/name: {{ include "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- if .Values.serviceAccount.labels }} +{{ toYaml .Values.serviceAccount.labels | indent 4 }} + {{- end }} +{{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ template "secrets-webhook.fullname" . }} +rules: + - apiGroups: + - "" + resources: + - secrets + - configmaps + verbs: + - "get" + {{- if .Values.secretsMutation }} + - "update" + {{- end }} + - apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - "get" + - apiGroups: + - "" + resources: + - configmaps + verbs: + - "create" + - "update" + - apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - "create" +{{- if .Values.rbac.psp.enabled }} + - apiGroups: + - extensions + resources: + - podsecuritypolicies + verbs: + - use + resourceNames: + - {{ template "secrets-webhook.fullname" . }} +{{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ template "secrets-webhook.fullname" . }}-limited +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {{ template "secrets-webhook.fullname" . }} +subjects: +- kind: ServiceAccount + namespace: {{ .Release.Namespace }} + name: {{ template "secrets-webhook.serviceAccountName" . }} +{{- if .Values.rbac.authDelegatorRole.enabled }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ template "secrets-webhook.fullname" . }}-auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: {{ template "secrets-webhook.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} + diff --git a/deploy/charts/secrets-webhook/templates/webhook-service.yaml b/deploy/charts/secrets-webhook/templates/webhook-service.yaml new file mode 100644 index 0000000..52e69a0 --- /dev/null +++ b/deploy/charts/secrets-webhook/templates/webhook-service.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "secrets-webhook.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + helm.sh/chart: {{ template "secrets-webhook.chart" . }} + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: mutating-webhook + {{- if .Values.service.annotations }} + annotations: +{{ toYaml .Values.service.annotations | indent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ template "secrets-webhook.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/deploy/charts/secrets-webhook/values.yaml b/deploy/charts/secrets-webhook/values.yaml new file mode 100644 index 0000000..bf158ae --- /dev/null +++ b/deploy/charts/secrets-webhook/values.yaml @@ -0,0 +1,302 @@ +# Default values for secrets-webhook. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + + +# -- Number of replicas +replicaCount: 2 + +# -- Enable debug logs for webhook +debug: false + +certificate: + # -- Should request cert-manager for getting a new CA and TLS certificate + useCertManager: false + # -- Should use an already externally defined Certificate by cert-manager + servingCertificate: null + # -- Should a new CA and TLS certificate be generated for the webhook + generate: true + server: + tls: + # -- Base64 encoded TLS certificate signed by the CA + crt: "" + # -- Base64 encoded private key of TLS certificate signed by the CA + key: "" + ca: + # -- Base64 encoded CA certificate + crt: "" + # -- Use extra names if you want to use the webhook via an ingress or a loadbalancer + extraAltNames: [] + # -- The number of days from the creation of the CA certificate until it expires + caLifespan: 3650 + # -- The number of days from the creation of the TLS certificate until it expires + certLifespan: 365 + +image: + # -- Container image repo that contains the admission server + repository: ghcr.io/bank-vaults/secrets-webhook + # -- Container image tag + tag: "" + # -- Container image pull policy + pullPolicy: IfNotPresent + # -- Container image pull secrets for private repositories + imagePullSecrets: [] + +service: + # -- Webhook service name + name: secrets-webhook + # -- Webhook service type + type: ClusterIP + # -- Webhook service external port + externalPort: 443 + # -- Webhook service internal port + internalPort: 8443 + # -- Webhook service annotations, e.g. if type is AWS LoadBalancer and you want to add security groups + annotations: {} + +ingress: + # -- Enable Webhook ingress + enabled: false + # -- Webhook ingress annotations + annotations: {} + # -- Webhook ingress host + host: "" + +webhookClientConfig: + # -- Use url if webhook should be contacted over loadbalancer or ingress instead of service object. + # By default, the mutating webhook uses the service of the webhook directly to contact webhook. + useUrl: false + # -- Set the url how the webhook should be contacted, including the protocol + url: https://example.com + +secretInit: + # -- Container image repo that contains the secret-init container + repository: ghcr.io/bank-vaults/secret-init + # -- Container image tag for the secret-init container + tag: "v0.1.0" + +# -- Custom environment variables available to webhook +env: {} + ## -- Vault image + # VAULT_IMAGE: hashicorp/vault:1.14.8 + # VAULT_CAPATH: /vault/tls + + ## -- Used when the pod that should get secret injected does not specify an imagePullSecret + # DEFAULT_IMAGE_PULL_SECRET: "" + # DEFAULT_IMAGE_PULL_SECRET_NAMESPACE: "" + # DEFAULT_IMAGE_PULL_SECRET_SERVICE_ACCOUNT: "" + + ## -- Define the webhook's timeout for Vault communication, if not defined individually in resources by annotations + # VAULT_CLIENT_TIMEOUT: "10s" + + ## -- Define the webhook's role in Vault used for authentication, if not defined individually in resources by annotations + # VAULT_ROLE: "" + + ## -- Cpu requests and limits for init-containers secret-init and copy-secret-init + # SECRET_INIT_CPU_REQUEST: "" + # SECRET_INIT_CPU_LIMIT: "" + + ## -- Memory requests and limits for init-containers secret-init and copy-secret-init + # SECRET_INIT_MEMORY_REQUEST: "" + # SECRET_INIT_MEMORY_LIMIT: "" + + ## -- Define remote log server for secret-init + # SECRET_INIT_LOG_SERVER: "" + +# -- Containers to run before the webhook containers are started +initContainers: [] + # - name: init-myservice + # image: busybox + # command: ['sh', '-c', 'until nslookup myservice; do echo waiting for myservice; sleep 2; done;'] + +metrics: + # -- Enable metrics service for the webhook + enabled: false + # -- Metrics service port + port: 8443 + serviceMonitor: + # -- Enable service monitor + enabled: false + # -- Service monitor scheme + scheme: https + tlsConfig: + # -- Skip TLS checks for service monitor + insecureSkipVerify: true + +securityContext: + # -- Run containers in webhook deployment as specified user + runAsUser: 65534 + # -- Allow process to gain more privileges than its parent process + allowPrivilegeEscalation: false + +# -- Pod security context for webhook deployment +podSecurityContext: {} + +# -- Extra volume definitions for webhook deployment +volumes: [] + # - name: vault-tls + # secret: + # secretName: vault-tls + +# -- Extra volume mounts for webhook deployment +volumeMounts: [] + # - name: vault-tls + # mountPath: /vault/tls + +# -- Extra annotations to add to pod metadata +podAnnotations: {} + +# -- Extra labels to add to the deployment and pods +labels: {} + # team: banzai + +# -- Resources to request for the deployment and pods +resources: {} + +# -- Node labels for pod assignment. +# Check: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#nodeselector +nodeSelector: {} + +# -- List of node tolerations for the pods. +# Check: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/ +tolerations: [] + +# -- Node affinity settings for the pods. +# Check: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ +affinity: {} + +# -- TopologySpreadConstraints to add for the pods. +# Check: https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ +topologySpreadConstraints: {} + +# -- Assign a PriorityClassName to pods if set. +# Check: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ +priorityClassName: "" + +# -- Liveness and readiness probes for the webhook container +livenessProbe: + initialDelaySeconds: 30 + failureThreshold: 3 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 +readinessProbe: + failureThreshold: 3 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + +rbac: + psp: + # -- Use pod security policy + enabled: false + authDelegatorRole: + # -- Bind `system:auth-delegator` ClusterRoleBinding to given `serviceAccount` + enabled: false + +serviceAccount: + # -- Specifies whether a service account should be created + create: true + # -- The name of the service account to use. + # If not set and `create` is true, a name is generated using the fullname template. + name: "" + # -- Labels to add to the service account + labels: {} + # -- Annotations to add to the service account. + # For example, use `iam.gke.io/gcp-service-account: gsa@project.iam.gserviceaccount.com` to enable GKE workload identity. + annotations: {} + +deployment: + # -- Rolling strategy for webhook deployment + strategy: {} + +# -- List of CustomResources to inject values from Vault, for example: ["ingresses", "servicemonitors"] +customResourceMutations: [] + +customResourcesFailurePolicy: Ignore + +# -- Enable injecting values from Vault to ConfigMaps. +# This can cause issues when used with Helm, so it is disabled by default. +configMapMutation: false + +# -- Enable injecting values from Vault to Secrets. +# Set to `false` in order to prevent secret values from being persisted in Kubernetes. +secretsMutation: true + +configMapFailurePolicy: Ignore + +podsFailurePolicy: Ignore + +secretsFailurePolicy: Ignore + +# -- Webhook sideEffect value +# Check: https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#side-effects +apiSideEffectValue: NoneOnDryRun + +# -- Namespace selector to use, will limit webhook scope (K8s version 1.15+) +namespaceSelector: + # @ignored + matchExpressions: + # https://kubernetes.io/docs/reference/labels-annotations-taints/#kubernetes-io-metadata-name + - key: kubernetes.io/metadata.name + operator: NotIn + values: + - kube-system + # matchLabels: + # vault-injection: enabled + +# -- Object selector to use, will limit webhook scope (K8s version 1.15+) +objectSelector: {} + # matchExpressions: + # - key: security.banzaicloud.io/mutate + # operator: NotIn + # values: + # - skip + # matchLabels: + # vault-injection: enabled + +secrets: + # -- Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + objectSelector: {} + # -- Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + namespaceSelector: {} + +pods: + # -- Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + objectSelector: {} + # -- Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + namespaceSelector: {} + +configMaps: + # -- Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + objectSelector: {} + # -- Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + namespaceSelector: {} + +customResources: + # -- Object selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + objectSelector: {} + # -- Namespace selector for secrets (overrides `objectSelector`); Requires K8s 1.15+ + namespaceSelector: {} + +podDisruptionBudget: + # -- Enables PodDisruptionBudget + enabled: true + # -- Represents the number of Pods that must be available (integer or percentage) + minAvailable: 1 + # -- Represents the number of Pods that can be unavailable (integer or percentage) + # maxUnavailable: 1 + +# -- Webhook timeoutSeconds value +timeoutSeconds: false + +# -- Allow pod to use the node network namespace +hostNetwork: false + +# -- The dns policy desired for the deployment. +# If you're using cilium (CNI) and you are required to set hostNetwork to true, +# then pods with webhooks must set the dnsPolicy to "ClusterFirstWithHostNet" +dnsPolicy: "" + +# -- Override cluster version +kubeVersion: "" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ac32c58 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,10 @@ +version: "3.9" + +services: + vault: + image: hashicorp/vault:1.14.8 + ports: + - 127.0.0.1:8200:8200 + environment: + SKIP_SETCAP: true + VAULT_DEV_ROOT_TOKEN_ID: 227e1cce-6bf7-30bb-2d2a-acc854318caf diff --git a/e2e/deploy/secrets-webhook/values.yaml b/e2e/deploy/secrets-webhook/values.yaml new file mode 100644 index 0000000..9744760 --- /dev/null +++ b/e2e/deploy/secrets-webhook/values.yaml @@ -0,0 +1,22 @@ +env: + VAULT_IMAGE: hashicorp/vault:1.14.8 + +replicaCount: 1 + +image: + pullPolicy: Never + +configMapMutation: true +configmapFailurePolicy: "Fail" +podsFailurePolicy: "Fail" +secretsFailurePolicy: "Fail" + +namespaceSelector: + matchExpressions: + # https://kubernetes.io/docs/reference/labels-annotations-taints/#kubernetes-io-metadata-name + - key: kubernetes.io/metadata.name + operator: NotIn + values: + - kube-system + - vault-operator + - secrets-webhook diff --git a/e2e/deploy/vault/rbac.yaml b/e2e/deploy/vault/rbac.yaml new file mode 100644 index 0000000..235cc0d --- /dev/null +++ b/e2e/deploy/vault/rbac.yaml @@ -0,0 +1,46 @@ +kind: ServiceAccount +apiVersion: v1 +metadata: + name: vault + +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: vault +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["*"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "update", "patch"] + +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: vault +roleRef: + kind: Role + name: vault + apiGroup: rbac.authorization.k8s.io +subjects: + - kind: ServiceAccount + name: vault + +--- +# This binding allows the deployed Vault instance to authenticate clients +# through Kubernetes ServiceAccounts (if configured so). +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: vault + namespace: default diff --git a/e2e/deploy/vault/vault.yaml b/e2e/deploy/vault/vault.yaml new file mode 100644 index 0000000..972b028 --- /dev/null +++ b/e2e/deploy/vault/vault.yaml @@ -0,0 +1,174 @@ +apiVersion: "vault.banzaicloud.com/v1alpha1" +kind: "Vault" +metadata: + name: "vault" +spec: + size: 1 + image: hashicorp/vault:1.14.8 + + # Specify the ServiceAccount where the Vault Pod and the Bank-Vaults configurer/unsealer is running + serviceAccount: vault + + # Specify the Service's type where the Vault Service is exposed + # Please note that some Ingress controllers like https://github.com/kubernetes/ingress-gce + # forces you to expose your Service on a NodePort + serviceType: ClusterIP + + # Use local disk to store Vault file data, see config section. + volumes: + - name: vault-file + persistentVolumeClaim: + claimName: vault-file + + volumeMounts: + - name: vault-file + mountPath: /vault/file + + # Support for distributing the generated CA certificate Secret to other namespaces. + # Define a list of namespaces or use ["*"] for all namespaces. + caNamespaces: + - "secrets-webhook" + + # Describe where you would like to store the Vault unseal keys and root token. + unsealConfig: + options: + # The preFlightChecks flag enables unseal and root token storage tests + # This is true by default + preFlightChecks: true + # The storeRootToken flag enables storing of root token in chosen storage + # This is true by default + storeRootToken: true + kubernetes: + secretNamespace: default + + # A YAML representation of a final vault config file. + # See https://www.vaultproject.io/docs/configuration/ for more information. + config: + storage: + file: + # Does not work with Garden + # path: "$${ .Env.VAULT_STORAGE_FILE }" # An example how Vault config environment interpolation can be used + path: /vault/file + listener: + tcp: + address: "0.0.0.0:8200" + # Uncommenting the following line and deleting tls_cert_file and tls_key_file disables TLS + # tls_disable: true + tls_cert_file: /vault/tls/server.crt + tls_key_file: /vault/tls/server.key + telemetry: + statsd_address: localhost:9125 + ui: true + + # See: https://banzaicloud.com/docs/bank-vaults/cli-tool/#example-external-vault-configuration + # The repository also contains a lot examples in the test/deploy and operator/deploy directories. + externalConfig: + policies: + - name: allow_secrets + rules: path "secret/*" { + capabilities = ["create", "read", "update", "delete", "list"] + } + - name: allow_pki + rules: path "pki/*" { + capabilities = ["create", "read", "update", "delete", "list"] + } + + groups: + - name: admin1 + policies: + - allow_secrets + metadata: + privileged: true + type: external + - name: admin2 + policies: + - allow_secrets + metadata: + privileged: true + type: external + + group-aliases: + - name: admin1 + mountpath: token + group: admin1 + + + auth: + - type: kubernetes + roles: + # Allow every pod in the default namespace to use the secret kv store + - name: default + bound_service_account_names: ["default", "secrets-webhook", "vault"] + bound_service_account_namespaces: ["default", "secrets-webhook"] + policies: ["allow_secrets", "allow_pki"] + ttl: 1h + + secrets: + - path: secret + type: kv + description: General secrets. + options: + version: 2 + + - path: pki + type: pki + description: Vault PKI Backend + config: + default_lease_ttl: 168h + max_lease_ttl: 720h + configuration: + config: + - name: urls + issuing_certificates: https://vault.default:8200/v1/pki/ca + crl_distribution_points: https://vault.default:8200/v1/pki/crl + root/generate: + - name: internal + common_name: vault.default + roles: + - name: default + allowed_domains: localhost,pod,svc,default + allow_subdomains: true + generate_lease: true + ttl: 1m + + # Allows writing some secrets to Vault (useful for development purposes). + # See https://www.vaultproject.io/docs/secrets/kv/index.html for more information. + startupSecrets: + - type: kv + path: secret/data/accounts/aws + data: + data: + AWS_ACCESS_KEY_ID: secretId + AWS_SECRET_ACCESS_KEY: s3cr3t + - type: kv + path: secret/data/dockerrepo + data: + data: + DOCKER_REPO_USER: dockerrepouser + DOCKER_REPO_PASSWORD: dockerrepopassword + - type: kv + path: secret/data/mysql + data: + data: + MYSQL_ROOT_PASSWORD: s3cr3t + MYSQL_PASSWORD: 3xtr3ms3cr3t + + secretInitsConfig: + - name: VAULT_LOG_LEVEL + value: debug + - name: VAULT_STORAGE_FILE + value: "/vault/file" + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-file +spec: + # https://kubernetes.io/docs/concepts/storage/persistent-volumes/#class-1 + # storageClassName: "" + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/e2e/kind.yaml b/e2e/kind.yaml new file mode 100644 index 0000000..18eb9ae --- /dev/null +++ b/e2e/kind.yaml @@ -0,0 +1,2 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 diff --git a/e2e/main_test.go b/e2e/main_test.go new file mode 100644 index 0000000..3e0887a --- /dev/null +++ b/e2e/main_test.go @@ -0,0 +1,312 @@ +// Copyright © 2023 Bank-Vaults Maintainers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build e2e +// +build e2e + +package e2e + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/e2e-framework/klient/conf" + "sigs.k8s.io/e2e-framework/klient/decoder" + "sigs.k8s.io/e2e-framework/klient/k8s/resources" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/env" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/envfuncs" + "sigs.k8s.io/e2e-framework/support/kind" + "sigs.k8s.io/e2e-framework/third_party/helm" +) + +// Upgrade this when a new version is released +const vaultOperatorVersion = "1.22.0" + +var testenv env.Environment + +func TestMain(m *testing.M) { + // See https://github.com/kubernetes-sigs/e2e-framework/issues/269 + // testenv = env.New() + testenv = &reverseFinishEnvironment{Environment: env.New()} + + if os.Getenv("LOG_VERBOSE") == "true" { + flags := flag.NewFlagSet("", flag.ContinueOnError) + klog.InitFlags(flags) + flags.Parse([]string{"-v", "4"}) + } + log.SetLogger(klog.NewKlogr()) + + bootstrap := strings.ToLower(os.Getenv("BOOTSTRAP")) != "false" + useRealCluster := !bootstrap || strings.ToLower(os.Getenv("USE_REAL_CLUSTER")) == "true" + + // Set up cluster + if useRealCluster { + path := conf.ResolveKubeConfigFile() + cfg := envconf.NewWithKubeConfig(path) + + if context := os.Getenv("USE_CONTEXT"); context != "" { + cfg.WithKubeContext(context) + } + + // See https://github.com/kubernetes-sigs/e2e-framework/issues/269 + // testenv = env.NewWithConfig(cfg) + testenv = &reverseFinishEnvironment{Environment: env.NewWithConfig(cfg)} + } else { + clusterName := envconf.RandomName("secrets-webhook-test", 32) + + kindCluster := kind.NewProvider() + if v := os.Getenv("KIND_K8S_VERSION"); v != "" { + kindCluster.WithOpts(kind.WithImage("kindest/node:" + v)) + testenv.Setup(envfuncs.CreateClusterWithConfig(kindCluster, clusterName, "kind.yaml")) + } else { + testenv.Setup(envfuncs.CreateCluster(kindCluster, clusterName)) + } + + testenv.Finish(envfuncs.DestroyCluster(clusterName)) + + if image := os.Getenv("LOAD_IMAGE"); image != "" { + testenv.Setup(envfuncs.LoadDockerImageToCluster(clusterName, image)) + } + + if imageArchive := os.Getenv("LOAD_IMAGE_ARCHIVE"); imageArchive != "" { + testenv.Setup(envfuncs.LoadImageArchiveToCluster(clusterName, imageArchive)) + } + } + + if bootstrap { + // Install vault-operator + testenv.Setup(installVaultOperator) + testenv.Finish(uninstallVaultOperator, envfuncs.DeleteNamespace("vault-operator")) + + testenv.Setup(envfuncs.CreateNamespace("secrets-webhook"), installVaultSecretsWebhook) + testenv.Finish(uninstallVaultSecretsWebhook, envfuncs.DeleteNamespace("secrets-webhook")) + + // Set up test namespace + // ns := envconf.RandomName("webhook-test", 16) + // testenv.Setup(envfuncs.CreateNamespace(ns)) + // testenv.Finish(envfuncs.DeleteNamespace(ns)) + + // Unsealing and Vault access only works in the default namespace at the moment + testenv.Setup(useNamespace("default")) + + testenv.Setup(installVault, waitForVaultTLS) + testenv.Finish(uninstallVault) + } else { + // Unsealing and Vault access only works in the default namespace at the moment + testenv.Setup(useNamespace("default")) + } + + os.Exit(testenv.Run(m)) +} + +func installVaultOperator(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + manager := helm.New(cfg.KubeconfigFile()) + + err := manager.RunInstall( + helm.WithName("vault-operator"), // This is weird that ReleaseName works differently, but it is what it is + helm.WithChart("oci://ghcr.io/bank-vaults/helm-charts/vault-operator"), + helm.WithNamespace("vault-operator"), + helm.WithArgs("--create-namespace"), + helm.WithVersion(vaultOperatorVersion), + helm.WithWait(), + helm.WithTimeout("2m"), + ) + if err != nil { + return ctx, fmt.Errorf("installing vault-operator: %w", err) + } + + return ctx, nil +} + +func uninstallVaultOperator(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + manager := helm.New(cfg.KubeconfigFile()) + + err := manager.RunUninstall( + helm.WithName("vault-operator"), + helm.WithNamespace("vault-operator"), + helm.WithWait(), + helm.WithTimeout("2m"), + ) + if err != nil { + return ctx, fmt.Errorf("uninstalling vault-operator: %w", err) + } + + return ctx, nil +} + +func installVaultSecretsWebhook(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + manager := helm.New(cfg.KubeconfigFile()) + + version := "latest" + if v := os.Getenv("WEBHOOK_VERSION"); v != "" { + version = v + } + + chart := "../deploy/charts/secrets-webhook/" + if v := os.Getenv("HELM_CHART"); v != "" { + chart = v + } + + err := manager.RunInstall( + helm.WithName("secrets-webhook"), // This is weird that ReleaseName works differently, but it is what it is + helm.WithChart(chart), + helm.WithNamespace("secrets-webhook"), + helm.WithArgs("-f", "deploy/secrets-webhook/values.yaml", "--set", "image.tag="+version), + helm.WithWait(), + helm.WithTimeout("2m"), + ) + if err != nil { + return ctx, fmt.Errorf("installing secrets-webhook: %w", err) + } + + return ctx, nil +} + +func uninstallVaultSecretsWebhook(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + manager := helm.New(cfg.KubeconfigFile()) + + err := manager.RunUninstall( + helm.WithName("secrets-webhook"), + helm.WithNamespace("secrets-webhook"), + helm.WithWait(), + helm.WithTimeout("2m"), + ) + if err != nil { + return ctx, fmt.Errorf("uninstalling secrets-webhook: %w", err) + } + + return ctx, nil +} + +func useNamespace(ns string) env.Func { + return func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + cfg.WithNamespace(ns) + + return ctx, nil + } +} + +func installVault(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + r, err := resources.New(cfg.Client().RESTConfig()) + if err != nil { + return ctx, err + } + + err = decoder.DecodeEachFile( + ctx, os.DirFS("deploy/vault"), "*", + decoder.CreateHandler(r), + decoder.MutateNamespace(cfg.Namespace()), + ) + if err != nil { + return ctx, err + } + + statefulSets := &appsv1.StatefulSetList{ + Items: []appsv1.StatefulSet{ + { + ObjectMeta: metav1.ObjectMeta{Name: "vault", Namespace: cfg.Namespace()}, + }, + }, + } + + // wait for the statefulSet to become available + err = wait.For(conditions.New(r).ResourcesFound(statefulSets), wait.WithTimeout(1*time.Minute)) + if err != nil { + return ctx, err + } + + time.Sleep(5 * time.Second) + + pod := v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "vault-0", Namespace: cfg.Namespace()}, + } + + // wait for the pod to become available + err = wait.For(conditions.New(r).PodReady(&pod), wait.WithTimeout(1*time.Minute)) + if err != nil { + return ctx, err + } + + return ctx, nil +} + +func waitForVaultTLS(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + vaultTLSSecrets := &v1.SecretList{ + Items: []v1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Name: "vault-tls", Namespace: cfg.Namespace()}, + }, + }, + } + + // wait for the vault-tls secret to become available + err := wait.For(conditions.New(cfg.Client().Resources()).ResourcesFound(vaultTLSSecrets), wait.WithTimeout(1*time.Minute)) + if err != nil { + return ctx, err + } + + return ctx, nil +} + +func uninstallVault(ctx context.Context, cfg *envconf.Config) (context.Context, error) { + r, err := resources.New(cfg.Client().RESTConfig()) + if err != nil { + return ctx, err + } + + err = decoder.DecodeEachFile( + ctx, os.DirFS("deploy/vault"), "*", + decoder.DeleteHandler(r), + decoder.MutateNamespace(cfg.Namespace()), + ) + + if err != nil { + return ctx, err + } + + return ctx, nil +} + +type reverseFinishEnvironment struct { + env.Environment + + finishFuncs []env.Func +} + +// Finish registers funcs that are executed at the end of the test suite in a reverse order. +func (e *reverseFinishEnvironment) Finish(f ...env.Func) env.Environment { + e.finishFuncs = append(f[:], e.finishFuncs...) + + return e +} + +// Run launches the test suite from within a TestMain. +func (e *reverseFinishEnvironment) Run(m *testing.M) int { + e.Environment.Finish(e.finishFuncs...) + + return e.Environment.Run(m) +} diff --git a/e2e/test/configmap.yaml b/e2e/test/configmap.yaml new file mode 100644 index 0000000..6e264bb --- /dev/null +++ b/e2e/test/configmap.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap + annotations: + vault.security.banzaicloud.io/vault-addr: "https://vault.default.svc.cluster.local:8200" + vault.security.banzaicloud.io/vault-role: "default" + vault.security.banzaicloud.io/vault-tls-secret: vault-tls + # vault.security.banzaicloud.io/vault-skip-verify: "true" + vault.security.banzaicloud.io/vault-path: "kubernetes" +data: + aws-access-key-id: vault:secret/data/accounts/aws#AWS_ACCESS_KEY_ID + aws-access-key-id-formatted: "vault:secret/data/accounts/aws#AWS key in base64: ${.AWS_ACCESS_KEY_ID | b64enc}" + aws-access-key-id-inline: "AWS_ACCESS_KEY_ID: ${vault:secret/data/accounts/aws#AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY}" +binaryData: + aws-access-key-id-binary: dmF1bHQ6c2VjcmV0L2RhdGEvYWNjb3VudHMvYXdzI0FXU19BQ0NFU1NfS0VZX0lE diff --git a/e2e/test/deployment-init-seccontext.yaml b/e2e/test/deployment-init-seccontext.yaml new file mode 100644 index 0000000..3c7042b --- /dev/null +++ b/e2e/test/deployment-init-seccontext.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment-init-seccontext +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: test-deployment-init-seccontext + template: + metadata: + labels: + app.kubernetes.io/name: test-deployment-init-seccontext + annotations: + vault.security.banzaicloud.io/vault-addr: "https://vault.default.svc.cluster.local:8200" + vault.security.banzaicloud.io/vault-role: "default" + vault.security.banzaicloud.io/vault-tls-secret: vault-tls + # vault.security.banzaicloud.io/vault-skip-verify: "true" + vault.security.banzaicloud.io/vault-path: "kubernetes" + vault.security.banzaicloud.io/run-as-non-root: "true" + vault.security.banzaicloud.io/run-as-user: "1000" + vault.security.banzaicloud.io/run-as-group: "1000" + spec: + containers: + - name: alpine + image: alpine + command: ["sh", "-c", "echo $AWS_SECRET_ACCESS_KEY && echo going to sleep... && sleep 10000"] + env: + - name: AWS_SECRET_ACCESS_KEY + value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY + resources: + limits: + memory: "128Mi" + cpu: "100m" diff --git a/e2e/test/deployment-seccontext.yaml b/e2e/test/deployment-seccontext.yaml new file mode 100644 index 0000000..d3fb96a --- /dev/null +++ b/e2e/test/deployment-seccontext.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment-seccontext +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: test-deployment-seccontext + template: + metadata: + labels: + app.kubernetes.io/name: test-deployment-seccontext + annotations: + vault.security.banzaicloud.io/vault-addr: "https://vault.default.svc.cluster.local:8200" + vault.security.banzaicloud.io/vault-role: "default" + vault.security.banzaicloud.io/vault-tls-secret: vault-tls + # vault.security.banzaicloud.io/vault-skip-verify: "true" + vault.security.banzaicloud.io/vault-path: "kubernetes" + # vault.security.banzaicloud.io/vault-agent: "true" + spec: + securityContext: + runAsUser: 1000 + initContainers: + - name: init-ubuntu + image: ubuntu + command: ["sh", "-c", "echo $AWS_SECRET_ACCESS_KEY && echo initContainers ready"] + env: + - name: AWS_SECRET_ACCESS_KEY + value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY + resources: + limits: + memory: "128Mi" + cpu: "100m" + containers: + - name: alpine + image: alpine + command: ["sh", "-c", "echo $AWS_SECRET_ACCESS_KEY && echo going to sleep... && sleep 10000"] + env: + - name: AWS_SECRET_ACCESS_KEY + value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY + resources: + limits: + memory: "128Mi" + cpu: "100m" diff --git a/e2e/test/deployment-template.yaml b/e2e/test/deployment-template.yaml new file mode 100644 index 0000000..e16e70c --- /dev/null +++ b/e2e/test/deployment-template.yaml @@ -0,0 +1,75 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/name: my-app + my-app.kubernetes.io/name: my-app-vault-agent + branches: "true" + name: my-app-vault-agent +data: + config.hcl: | + vault { + // This is needed until https://github.com/hashicorp/vault/issues/7889 + // gets fixed, otherwise it is automated by the webhook. + ca_cert = "/vault/tls/ca.crt" + } + auto_auth { + method "kubernetes" { + mount_path = "auth/kubernetes" + config = { + role = "default" + } + } + sink "file" { + config = { + path = "/vault/.vault-token" + } + } + } + template { + contents = < 0 { + // Serving metrics without TLS on separated address + go mutatingWebhook.ServeMetrics(telemetryAddress, promHandler) + } else { + mux.Handle("/metrics", promHandler) + } + + if tlsCertFile == "" && tlsPrivateKeyFile == "" { + logger.Info(fmt.Sprintf("Listening on http://%s", listenAddress)) + err = http.ListenAndServe(listenAddress, mux) + } else { + srv := newHTTPServer(tlsCertFile, tlsPrivateKeyFile, listenAddress, mux) + logger.Info(fmt.Sprintf("Listening on https://%s", listenAddress)) + err = srv.ListenAndServeTLS("", "") + } + + if err != nil { + logger.Error(fmt.Errorf("error serving webhook: %w", err).Error()) + os.Exit(1) + } +} diff --git a/pkg/common/common.go b/pkg/common/common.go new file mode 100644 index 0000000..1de60a3 --- /dev/null +++ b/pkg/common/common.go @@ -0,0 +1,92 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "strings" +) + +const ( + // Webhook annotations + // ref: https://bank-vaults.dev/docs/mutating-webhook/annotations/ + PSPAllowPrivilegeEscalationAnnotation = "vault.security.banzaicloud.io/psp-allow-privilege-escalation" + RunAsNonRootAnnotation = "vault.security.banzaicloud.io/run-as-non-root" + RunAsUserAnnotation = "vault.security.banzaicloud.io/run-as-user" + RunAsGroupAnnotation = "vault.security.banzaicloud.io/run-as-group" + ReadOnlyRootFsAnnotation = "vault.security.banzaicloud.io/readonly-root-fs" + RegistrySkipVerifyAnnotation = "vault.security.banzaicloud.io/registry-skip-verify" + MutateAnnotation = "vault.security.banzaicloud.io/mutate" + MutateProbesAnnotation = "vault.security.banzaicloud.io/mutate-probes" + + // Secret-init annotations + SecretInitDaemonAnnotation = "vault.security.banzaicloud.io/secret-init-daemon" + SecretInitDelayAnnotation = "vault.security.banzaicloud.io/secret-init-delay" + SecretInitJSONLogAnnotation = "vault.security.banzaicloud.io/secret-init-json-log" + SecretInitImageAnnotation = "vault.security.banzaicloud.io/secret-init-image" + SecretInitImagePullPolicyAnnotation = "vault.security.banzaicloud.io/secret-init-image-pull-policy" + + // Vault annotations + VaultAddrAnnotation = "vault.security.banzaicloud.io/vault-addr" + VaultImageAnnotation = "vault.security.banzaicloud.io/vault-image" + VaultImagePullPolicyAnnotation = "vault.security.banzaicloud.io/vault-image-pull-policy" + VaultRoleAnnotation = "vault.security.banzaicloud.io/vault-role" + VaultPathAnnotation = "vault.security.banzaicloud.io/vault-path" + VaultSkipVerifyAnnotation = "vault.security.banzaicloud.io/vault-skip-verify" + VaultTLSSecretAnnotation = "vault.security.banzaicloud.io/vault-tls-secret" + VaultIgnoreMissingSecretsAnnotation = "vault.security.banzaicloud.io/vault-ignore-missing-secrets" + VaultClientTimeoutAnnotation = "vault.security.banzaicloud.io/vault-client-timeout" + TransitKeyIDAnnotation = "vault.security.banzaicloud.io/transit-key-id" + TransitPathAnnotation = "vault.security.banzaicloud.io/transit-path" + VaultAuthMethodAnnotation = "vault.security.banzaicloud.io/vault-auth-method" + TransitBatchSizeAnnotation = "vault.security.banzaicloud.io/transit-batch-size" + TokenAuthMountAnnotation = "vault.security.banzaicloud.io/token-auth-mount" + VaultServiceaccountAnnotation = "vault.security.banzaicloud.io/vault-serviceaccount" + VaultNamespaceAnnotation = "vault.security.banzaicloud.io/vault-namespace" + ServiceAccountTokenVolumeNameAnnotation = "vault.security.banzaicloud.io/service-account-token-volume-name" + LogLevelAnnotation = "vault.security.banzaicloud.io/log-level" + VaultPassthroughAnnotation = "vault.security.banzaicloud.io/vault-passthrough" + VaultFromPathAnnotation = "vault.security.banzaicloud.io/vault-from-path" + + // Vault agent annotations + // ref: https://bank-vaults.dev/docs/mutating-webhook/vault-agent-templating/ + VaultAgentAnnotation = "vault.security.banzaicloud.io/vault-agent" + VaultAgentConfigmapAnnotation = "vault.security.banzaicloud.io/vault-agent-configmap" + VaultAgentOnceAnnotation = "vault.security.banzaicloud.io/vault-agent-once" + VaultAgentShareProcessNamespaceAnnotation = "vault.security.banzaicloud.io/vault-agent-share-process-namespace" + VaultAgentCPUAnnotation = "vault.security.banzaicloud.io/vault-agent-cpu" + VaultAgentCPULimitAnnotation = "vault.security.banzaicloud.io/vault-agent-cpu-limit" + VaultAgentCPURequestAnnotation = "vault.security.banzaicloud.io/vault-agent-cpu-request" + VaultAgentMemoryAnnotation = "vault.security.banzaicloud.io/vault-agent-memory" + VaultAgentMemoryLimitAnnotation = "vault.security.banzaicloud.io/vault-agent-memory-limit" + VaultAgentMemoryRequestAnnotation = "vault.security.banzaicloud.io/vault-agent-memory-request" + VaultConfigfilePathAnnotation = "vault.security.banzaicloud.io/vault-configfile-path" + VaultAgentEnvVariablesAnnotation = "vault.security.banzaicloud.io/vault-agent-env-variables" + + // Consul template annotations + // https://bank-vaults.dev/docs/mutating-webhook/consul-template/ + VaultConsulTemplateConfigmapAnnotation = "vault.security.banzaicloud.io/vault-ct-configmap" + VaultConsulTemplateImageAnnotation = "vault.security.banzaicloud.io/vault-ct-image" + VaultConsulTemplateOnceAnnotation = "vault.security.banzaicloud.io/vault-ct-once" + VaultConsulTemplatePullPolicyAnnotation = "vault.security.banzaicloud.io/vault-ct-pull-policy" + VaultConsulTemplateShareProcessNamespaceAnnotation = "vault.security.banzaicloud.io/vault-ct-share-process-namespace" + VaultConsulTemplateCPUAnnotation = "vault.security.banzaicloud.io/vault-ct-cpu" + VaultConsulTemplateMemoryAnnotation = "vault.security.banzaicloud.io/vault-ct-memory" + VaultConsuleTemplateSecretsMountPathAnnotation = "vault.security.banzaicloud.io/vault-ct-secrets-mount-path" + VaultConsuleTemplateInjectInInitcontainersAnnotation = "vault.security.banzaicloud.io/vault-ct-inject-in-initcontainers" +) + +func HasVaultPrefix(value string) bool { + return strings.HasPrefix(value, "vault:") || strings.HasPrefix(value, ">>vault:") +} diff --git a/pkg/webhook/client_logger.go b/pkg/webhook/client_logger.go new file mode 100644 index 0000000..73d6765 --- /dev/null +++ b/pkg/webhook/client_logger.go @@ -0,0 +1,59 @@ +// Copyright © 2023 Bank-Vaults Maintainers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "log/slog" + + "github.com/bank-vaults/vault-sdk/vault" +) + +var _ vault.Logger = &clientLogger{} + +type clientLogger struct { + logger *slog.Logger +} + +func (l clientLogger) Trace(msg string, args ...map[string]interface{}) { + l.Debug(msg, args...) +} + +func (l clientLogger) Debug(msg string, args ...map[string]interface{}) { + l.logger.Debug(msg, l.argsToAttrs(args...)...) +} + +func (l clientLogger) Info(msg string, args ...map[string]interface{}) { + l.logger.Info(msg, l.argsToAttrs(args...)...) +} + +func (l clientLogger) Warn(msg string, args ...map[string]interface{}) { + l.logger.Warn(msg, l.argsToAttrs(args...)...) +} + +func (l clientLogger) Error(msg string, args ...map[string]interface{}) { + l.logger.Error(msg, l.argsToAttrs(args...)...) +} + +func (clientLogger) argsToAttrs(args ...map[string]interface{}) []any { + var attrs []any + + for _, arg := range args { + for key, value := range arg { + attrs = append(attrs, slog.Any(key, value)) + } + } + + return attrs +} diff --git a/pkg/webhook/config.go b/pkg/webhook/config.go new file mode 100644 index 0000000..eb7c1be --- /dev/null +++ b/pkg/webhook/config.go @@ -0,0 +1,529 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "strconv" + "time" + + "github.com/slok/kubewebhook/v2/pkg/model" + "github.com/spf13/viper" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/bank-vaults/secrets-webhook/pkg/common" +) + +// Config represents the configuration for the webhook +type Config struct { + PspAllowPrivilegeEscalation bool + RunAsNonRoot bool + RunAsUser int64 + RunAsGroup int64 + ReadOnlyRootFilesystem bool + RegistrySkipVerify bool + Mutate bool + MutateProbes bool +} + +// SecretInitConfig represents the configuration for the secret-init container +type SecretInitConfig struct { + Daemon bool + Delay time.Duration + LogLevel string + JSONLog string + Image string + ImagePullPolicy corev1.PullPolicy + LogServer string + CPURequest resource.Quantity + MemoryRequest resource.Quantity + CPULimit resource.Quantity + MemoryLimit resource.Quantity +} + +// VaultConfig represents vault options +type VaultConfig struct { + ObjectNamespace string + Addr string + AuthMethod string + Role string + Path string + SkipVerify bool + TLSSecret string + ClientTimeout time.Duration + UseAgent bool + TransitKeyID string + TransitPath string + TransitBatchSize int + CtConfigMap string + CtImage string + CtInjectInInitcontainers bool + CtOnce bool + CtImagePullPolicy corev1.PullPolicy + CtShareProcess bool + CtShareProcessDefault string + CtCPU resource.Quantity + CtMemory resource.Quantity + ConfigfilePath string + AgentConfigMap string + AgentOnce bool + AgentShareProcess bool + AgentShareProcessDefault string + AgentCPULimit resource.Quantity + AgentMemoryLimit resource.Quantity + AgentCPURequest resource.Quantity + AgentMemoryRequest resource.Quantity + AgentImage string + AgentImagePullPolicy corev1.PullPolicy + AgentEnvVariables string + ServiceAccountTokenVolumeName string + TokenAuthMount string + VaultNamespace string + VaultServiceAccount string + Token string + IgnoreMissingSecrets string + Passthrough string + LogLevel string + FromPath string +} + +func parseConfig(obj metav1.Object) Config { + Config := Config{} + + annotations := obj.GetAnnotations() + + if val, ok := annotations[common.MutateAnnotation]; ok { + Config.Mutate, _ = strconv.ParseBool(val) + } + + if val, ok := annotations[common.PSPAllowPrivilegeEscalationAnnotation]; ok { + Config.PspAllowPrivilegeEscalation, _ = strconv.ParseBool(val) + } + + if val, ok := annotations[common.RunAsNonRootAnnotation]; ok { + Config.RunAsNonRoot, _ = strconv.ParseBool(val) + } + + if val, ok := annotations[common.RunAsUserAnnotation]; ok { + Config.RunAsUser, _ = strconv.ParseInt(val, 10, 64) + } + + if val, ok := annotations[common.RunAsGroupAnnotation]; ok { + Config.RunAsGroup, _ = strconv.ParseInt(val, 10, 64) + } + + if val, ok := annotations[common.ReadOnlyRootFsAnnotation]; ok { + Config.ReadOnlyRootFilesystem, _ = strconv.ParseBool(val) + } + + if val, ok := annotations[common.RegistrySkipVerifyAnnotation]; ok { + Config.RegistrySkipVerify, _ = strconv.ParseBool(val) + } + + if val, ok := annotations[common.MutateProbesAnnotation]; ok { + Config.MutateProbes, _ = strconv.ParseBool(val) + } + + return Config +} + +func parseSecretInitConfig(obj metav1.Object) SecretInitConfig { + secretInitConfig := SecretInitConfig{} + + annotations := obj.GetAnnotations() + + if val, ok := annotations[common.SecretInitDaemonAnnotation]; ok { + secretInitConfig.Daemon, _ = strconv.ParseBool(val) + } else { + secretInitConfig.Daemon, _ = strconv.ParseBool(viper.GetString("secret_init_daemon")) + } + + if val, ok := annotations[common.SecretInitDelayAnnotation]; ok { + secretInitConfig.Delay, _ = time.ParseDuration(val) + } else { + secretInitConfig.Delay, _ = time.ParseDuration(viper.GetString("secret_init_delay")) + } + + if val, ok := annotations[common.SecretInitJSONLogAnnotation]; ok { + secretInitConfig.JSONLog = val + } else { + secretInitConfig.JSONLog = viper.GetString("secret_init_json_log") + } + + if val, ok := annotations[common.SecretInitImageAnnotation]; ok { + secretInitConfig.Image = val + } else { + secretInitConfig.Image = viper.GetString("secret_init_image") + } + + secretInitConfig.LogServer = viper.GetString("SECRET_INIT_LOG_SERVER") + + secretInitConfig.LogLevel = viper.GetString("SECRET_INIT_LOG_LEVEL") + + if val, ok := annotations[common.SecretInitImagePullPolicyAnnotation]; ok { + secretInitConfig.ImagePullPolicy = getPullPolicy(val) + } else { + secretInitConfig.ImagePullPolicy = getPullPolicy(viper.GetString("secret_init_image_pull_policy")) + } + + if val, err := resource.ParseQuantity(viper.GetString("SECRET_INIT_CPU_REQUEST")); err == nil { + secretInitConfig.CPURequest = val + } else { + secretInitConfig.CPURequest = resource.MustParse("50m") + } + + if val, err := resource.ParseQuantity(viper.GetString("SECRET_INIT_MEMORY_REQUEST")); err == nil { + secretInitConfig.MemoryRequest = val + } else { + secretInitConfig.MemoryRequest = resource.MustParse("64Mi") + } + + if val, err := resource.ParseQuantity(viper.GetString("SECRET_INIT_CPU_LIMIT")); err == nil { + secretInitConfig.CPULimit = val + } else { + secretInitConfig.CPULimit = resource.MustParse("250m") + } + + if val, err := resource.ParseQuantity(viper.GetString("SECRET_INIT_MEMORY_LIMIT")); err == nil { + secretInitConfig.MemoryLimit = val + } else { + secretInitConfig.MemoryLimit = resource.MustParse("64Mi") + } + + return secretInitConfig +} + +func parseVaultConfig(obj metav1.Object, ar *model.AdmissionReview) VaultConfig { + vaultConfig := VaultConfig{ + ObjectNamespace: ar.Namespace, + } + + annotations := obj.GetAnnotations() + + if val, ok := annotations[common.VaultAddrAnnotation]; ok { + vaultConfig.Addr = val + } else { + vaultConfig.Addr = viper.GetString("vault_addr") + } + + if val, ok := annotations[common.VaultRoleAnnotation]; ok { + vaultConfig.Role = val + } else { + if val := viper.GetString("vault_role"); val != "" { + vaultConfig.Role = val + } else { + switch p := obj.(type) { + case *corev1.Pod: + vaultConfig.Role = p.Spec.ServiceAccountName + default: + vaultConfig.Role = "default" + } + } + } + + if val, ok := annotations[common.VaultAuthMethodAnnotation]; ok { + vaultConfig.AuthMethod = val + } else { + vaultConfig.AuthMethod = viper.GetString("vault_auth_method") + } + + if val, ok := annotations[common.VaultPathAnnotation]; ok { + vaultConfig.Path = val + } else { + vaultConfig.Path = viper.GetString("vault_path") + } + + // TODO: Check for flag to verify we want to use namespace-local SAs instead of the vault webhook namespaces SA + if val, ok := annotations[common.VaultServiceaccountAnnotation]; ok { + vaultConfig.VaultServiceAccount = val + } else { + vaultConfig.VaultServiceAccount = viper.GetString("vault_serviceaccount") + } + + if val, ok := annotations[common.VaultSkipVerifyAnnotation]; ok { + vaultConfig.SkipVerify, _ = strconv.ParseBool(val) + } else { + vaultConfig.SkipVerify = viper.GetBool("vault_skip_verify") + } + + if val, ok := annotations[common.VaultTLSSecretAnnotation]; ok { + vaultConfig.TLSSecret = val + } else { + vaultConfig.TLSSecret = viper.GetString("vault_tls_secret") + } + + if val, ok := annotations[common.VaultClientTimeoutAnnotation]; ok { + vaultConfig.ClientTimeout, _ = time.ParseDuration(val) + } else { + vaultConfig.ClientTimeout, _ = time.ParseDuration(viper.GetString("vault_client_timeout")) + } + + if val, ok := annotations[common.VaultAgentAnnotation]; ok { + vaultConfig.UseAgent, _ = strconv.ParseBool(val) + } else { + vaultConfig.UseAgent, _ = strconv.ParseBool(viper.GetString("vault_agent")) + } + + if val, ok := annotations[common.VaultConsulTemplateConfigmapAnnotation]; ok { + vaultConfig.CtConfigMap = val + } else { + vaultConfig.CtConfigMap = "" + } + + if val, ok := annotations[common.ServiceAccountTokenVolumeNameAnnotation]; ok { + vaultConfig.ServiceAccountTokenVolumeName = val + } else if viper.GetString("SERVICE_ACCOUNT_TOKEN_VOLUME_NAME") != "" { + vaultConfig.ServiceAccountTokenVolumeName = viper.GetString("SERVICE_ACCOUNT_TOKEN_VOLUME_NAME") + } else { + vaultConfig.ServiceAccountTokenVolumeName = "/var/run/secrets/kubernetes.io/serviceaccount" + } + + if val, ok := annotations[common.VaultConsulTemplateImageAnnotation]; ok { + vaultConfig.CtImage = val + } else { + vaultConfig.CtImage = viper.GetString("vault_ct_image") + } + + if val, ok := annotations[common.VaultIgnoreMissingSecretsAnnotation]; ok { + vaultConfig.IgnoreMissingSecrets = val + } else { + vaultConfig.IgnoreMissingSecrets = viper.GetString("vault_ignore_missing_secrets") + } + if val, ok := annotations[common.VaultPassthroughAnnotation]; ok { + vaultConfig.Passthrough = val + } else { + vaultConfig.Passthrough = viper.GetString("vault_passthrough") + } + if val, ok := annotations[common.VaultConfigfilePathAnnotation]; ok { + vaultConfig.ConfigfilePath = val + } else if val, ok := annotations[common.VaultConsuleTemplateSecretsMountPathAnnotation]; ok { + vaultConfig.ConfigfilePath = val + } else { + vaultConfig.ConfigfilePath = "/vault/secrets" + } + + if val, ok := annotations[common.VaultConsulTemplatePullPolicyAnnotation]; ok { + vaultConfig.CtImagePullPolicy = getPullPolicy(val) + } else { + vaultConfig.CtImagePullPolicy = getPullPolicy(viper.GetString("vault_ct_pull_policy")) + } + + if val, ok := annotations[common.VaultConsulTemplateOnceAnnotation]; ok { + vaultConfig.CtOnce, _ = strconv.ParseBool(val) + } else { + vaultConfig.CtOnce = false + } + + if val, err := resource.ParseQuantity(annotations[common.VaultConsulTemplateCPUAnnotation]); err == nil { + vaultConfig.CtCPU = val + } else { + vaultConfig.CtCPU = resource.MustParse("100m") + } + + if val, err := resource.ParseQuantity(annotations[common.VaultConsulTemplateMemoryAnnotation]); err == nil { + vaultConfig.CtMemory = val + } else { + vaultConfig.CtMemory = resource.MustParse("128Mi") + } + + if val, ok := annotations[common.VaultConsulTemplateShareProcessNamespaceAnnotation]; ok { + vaultConfig.CtShareProcessDefault = "found" + vaultConfig.CtShareProcess, _ = strconv.ParseBool(val) + } else { + vaultConfig.CtShareProcessDefault = "empty" + vaultConfig.CtShareProcess = false + } + + if val, ok := annotations[common.LogLevelAnnotation]; ok { + vaultConfig.LogLevel = val + } else { + vaultConfig.LogLevel = viper.GetString("vault_log_level") + } + + if val, ok := annotations[common.TransitKeyIDAnnotation]; ok { + vaultConfig.TransitKeyID = val + } else { + vaultConfig.TransitKeyID = viper.GetString("transit_key_id") + } + + if val, ok := annotations[common.TransitPathAnnotation]; ok { + vaultConfig.TransitPath = val + } else { + vaultConfig.TransitPath = viper.GetString("transit_path") + } + + if val, ok := annotations[common.VaultAgentConfigmapAnnotation]; ok { + vaultConfig.AgentConfigMap = val + } else { + vaultConfig.AgentConfigMap = "" + } + + if val, ok := annotations[common.VaultAgentOnceAnnotation]; ok { + vaultConfig.AgentOnce, _ = strconv.ParseBool(val) + } else { + vaultConfig.AgentOnce = false + } + + // This is done to preserve backwards compatibility with vault-agent-cpu + if val, err := resource.ParseQuantity(annotations[common.VaultAgentCPUAnnotation]); err == nil { + vaultConfig.AgentCPULimit = val + } else if val, err := resource.ParseQuantity(annotations[common.VaultAgentCPULimitAnnotation]); err == nil { + vaultConfig.AgentCPULimit = val + } else { + vaultConfig.AgentCPULimit = resource.MustParse("100m") + } + + // This is done to preserve backwards compatibility with vault-agent-memory + if val, err := resource.ParseQuantity(annotations[common.VaultAgentMemoryAnnotation]); err == nil { + vaultConfig.AgentMemoryLimit = val + } else if val, err := resource.ParseQuantity(annotations[common.VaultAgentMemoryLimitAnnotation]); err == nil { + vaultConfig.AgentMemoryLimit = val + } else { + vaultConfig.AgentMemoryLimit = resource.MustParse("128Mi") + } + + if val, err := resource.ParseQuantity(annotations[common.VaultAgentCPURequestAnnotation]); err == nil { + vaultConfig.AgentCPURequest = val + } else { + vaultConfig.AgentCPURequest = resource.MustParse("100m") + } + + if val, err := resource.ParseQuantity(annotations[common.VaultAgentMemoryRequestAnnotation]); err == nil { + vaultConfig.AgentMemoryRequest = val + } else { + vaultConfig.AgentMemoryRequest = resource.MustParse("128Mi") + } + + if val, ok := annotations[common.VaultAgentShareProcessNamespaceAnnotation]; ok { + vaultConfig.AgentShareProcessDefault = "found" + vaultConfig.AgentShareProcess, _ = strconv.ParseBool(val) + } else { + vaultConfig.AgentShareProcessDefault = "empty" + vaultConfig.AgentShareProcess = false + } + + if val, ok := annotations[common.VaultFromPathAnnotation]; ok { + vaultConfig.FromPath = val + } + + if val, ok := annotations[common.TokenAuthMountAnnotation]; ok { + vaultConfig.TokenAuthMount = val + } + + if val, ok := annotations[common.VaultImageAnnotation]; ok { + vaultConfig.AgentImage = val + } else { + vaultConfig.AgentImage = viper.GetString("vault_image") + } + if val, ok := annotations[common.VaultImagePullPolicyAnnotation]; ok { + vaultConfig.AgentImagePullPolicy = getPullPolicy(val) + } else { + vaultConfig.AgentImagePullPolicy = getPullPolicy(viper.GetString("vault_image_pull_policy")) + } + + if val, ok := annotations[common.VaultAgentEnvVariablesAnnotation]; ok { + vaultConfig.AgentEnvVariables = val + } + + if val, ok := annotations[common.VaultNamespaceAnnotation]; ok { + vaultConfig.VaultNamespace = val + } else { + vaultConfig.VaultNamespace = viper.GetString("VAULT_NAMESPACE") + } + + if val, ok := annotations[common.VaultConsuleTemplateInjectInInitcontainersAnnotation]; ok { + vaultConfig.CtInjectInInitcontainers, _ = strconv.ParseBool(val) + } else { + vaultConfig.CtInjectInInitcontainers = false + } + + if val, ok := annotations[common.TransitBatchSizeAnnotation]; ok { + batchSize, _ := strconv.ParseInt(val, 10, 32) + vaultConfig.TransitBatchSize = int(batchSize) + } else { + vaultConfig.TransitBatchSize = viper.GetInt("transit_batch_size") + } + + vaultConfig.Token = viper.GetString("vault_token") + + return vaultConfig +} + +func getPullPolicy(pullPolicyStr string) corev1.PullPolicy { + switch pullPolicyStr { + case "Never", "never": + return corev1.PullNever + case "Always", "always": + return corev1.PullAlways + case "IfNotPresent", "ifnotpresent": + return corev1.PullIfNotPresent + } + + return corev1.PullIfNotPresent +} + +func SetConfigDefaults() { + viper.SetDefault("vault_image", "hashicorp/vault:latest") + viper.SetDefault("vault_image_pull_policy", string(corev1.PullIfNotPresent)) + viper.SetDefault("secret_init_image", "ghcr.io/bank-vaults/secret-init:latest") + viper.SetDefault("secret_init_image_pull_policy", string(corev1.PullIfNotPresent)) + viper.SetDefault("vault_ct_image", "hashicorp/consul-template:0.32.0") + viper.SetDefault("vault_ct_pull_policy", string(corev1.PullIfNotPresent)) + viper.SetDefault("vault_addr", "https://vault:8200") + viper.SetDefault("vault_skip_verify", "false") + viper.SetDefault("vault_path", "kubernetes") + viper.SetDefault("vault_auth_method", "jwt") + viper.SetDefault("vault_role", "") + viper.SetDefault("vault_tls_secret", "") + viper.SetDefault("vault_client_timeout", "10s") + viper.SetDefault("vault_agent", "false") + viper.SetDefault("secret_init_daemon", "false") + viper.SetDefault("vault_ct_share_process_namespace", "") + viper.SetDefault("psp_allow_privilege_escalation", "false") + viper.SetDefault("run_as_non_root", "false") + viper.SetDefault("run_as_user", "0") + viper.SetDefault("run_as_group", "0") + viper.SetDefault("readonly_root_fs", "false") + viper.SetDefault("vault_ignore_missing_secrets", "false") + viper.SetDefault("vault_passthrough", "") + viper.SetDefault("mutate_configmap", "false") + viper.SetDefault("tls_cert_file", "") + viper.SetDefault("tls_private_key_file", "") + viper.SetDefault("listen_address", ":8443") + viper.SetDefault("telemetry_listen_address", "") + viper.SetDefault("transit_key_id", "") + viper.SetDefault("transit_path", "") + viper.SetDefault("transit_batch_size", 25) + viper.SetDefault("default_image_pull_secret", "") + viper.SetDefault("default_image_pull_secret_service_account", "") + viper.SetDefault("default_image_pull_secret_namespace", "") + viper.SetDefault("registry_skip_verify", "false") + viper.SetDefault("secret_init_json_log", "false") + // Used by the webhook + viper.SetDefault("log_level", "info") + // Used by vault via secret-init + viper.SetDefault("vault_log_level", "info") + viper.SetDefault("vault_agent_share_process_namespace", "") + viper.SetDefault("SECRET_INIT_CPU_REQUEST", "") + viper.SetDefault("SECRET_INIT_MEMORY_REQUEST", "") + viper.SetDefault("SECRET_INIT_CPU_LIMIT", "") + viper.SetDefault("SECRET_INIT_MEMORY_LIMIT", "") + viper.SetDefault("SECRET_INIT_LOG_SERVER", "") + viper.SetDefault("SECRET_INIT_LOG_LEVEL", "info") + viper.SetDefault("VAULT_NAMESPACE", "") + + viper.AutomaticEnv() +} diff --git a/pkg/webhook/configmap.go b/pkg/webhook/configmap.go new file mode 100644 index 0000000..ea49ebf --- /dev/null +++ b/pkg/webhook/configmap.go @@ -0,0 +1,99 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "encoding/base64" + + "emperror.dev/errors" + "github.com/bank-vaults/internal/injector" + corev1 "k8s.io/api/core/v1" + + "github.com/bank-vaults/secrets-webhook/pkg/common" +) + +func configMapNeedsMutation(configMap *corev1.ConfigMap) bool { + for _, value := range configMap.Data { + if common.HasVaultPrefix(value) || injector.HasInlineVaultDelimiters(value) { + return true + } + } + for _, value := range configMap.BinaryData { + if common.HasVaultPrefix(string(value)) { + return true + } + } + + return false +} + +func (mw *MutatingWebhook) MutateConfigMap(configMap *corev1.ConfigMap, vaultConfig VaultConfig) error { + // do an early exit and don't construct the Vault client if not needed + if !configMapNeedsMutation(configMap) { + return nil + } + + vaultClient, err := mw.newVaultClient(vaultConfig) + if err != nil { + return errors.Wrap(err, "failed to create vault client") + } + + defer vaultClient.Close() + + config := injector.Config{ + TransitKeyID: vaultConfig.TransitKeyID, + TransitPath: vaultConfig.TransitPath, + TransitBatchSize: vaultConfig.TransitBatchSize, + } + secretInjector := injector.NewSecretInjector(config, vaultClient, nil, logger) + + configMap.Data, err = secretInjector.GetDataFromVault(configMap.Data) + if err != nil { + return err + } + + for key, value := range configMap.BinaryData { + if common.HasVaultPrefix(string(value)) { + binaryData := map[string]string{ + key: string(value), + } + err := mw.mutateConfigMapBinaryData(configMap, binaryData, &secretInjector) + if err != nil { + return err + } + } + } + + return nil +} + +func (mw *MutatingWebhook) mutateConfigMapBinaryData(configMap *corev1.ConfigMap, data map[string]string, secretInjector *injector.SecretInjector) error { + mapData, err := secretInjector.GetDataFromVault(data) + if err != nil { + return err + } + + for key, value := range mapData { + // binary data are stored in base64 inside vault + // we need to decode base64 since k8s will encode this data too + valueBytes, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return errors.Wrapf(err, "failed to decode ConfigMap binary data") + } + configMap.BinaryData[key] = valueBytes + } + + return nil +} diff --git a/pkg/webhook/configmap_test.go b/pkg/webhook/configmap_test.go new file mode 100644 index 0000000..3d62a4b --- /dev/null +++ b/pkg/webhook/configmap_test.go @@ -0,0 +1,73 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build integration +// +build integration + +package webhook + +import ( + "testing" + + "github.com/bank-vaults/vault-sdk/vault" + vaultapi "github.com/hashicorp/vault/api" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +func TestMutateConfigMap(t *testing.T) { + config := vaultapi.DefaultConfig() + if config.Error != nil { + assert.NoError(t, config.Error) + } + config.Address = "http://localhost:8200" + + client, err := vault.NewClientFromConfig(config) + assert.NoError(t, err) + + _, err = client.RawClient().Logical().Write("secret/data/account", vault.NewData(0, map[string]interface{}{"access_key": "superusername", "secret_key": "secret"})) + assert.NoError(t, err) + + t.Cleanup(func() { + _, err = client.RawClient().Logical().Delete("secret/metadata/account") + assert.NoError(t, err) + }) + + mw := MutatingWebhook{} + + configMap := corev1.ConfigMap{ + Data: map[string]string{ + "aws-access-key-id": "vault:secret/data/account#access_key", + "appsettings-inline": `{ + "Credentials": { + "ACCESS_KEY": "${vault:secret/data/account#access_key}", + "SECRET_KEY": "${vault:secret/data/account#secret_key}" + } + }`, + }, + } + + err = mw.MutateConfigMap(&configMap, VaultConfig{Addr: config.Address}) + assert.NoError(t, err) + + assert.Equal(t, map[string]string{ + "aws-access-key-id": "superusername", + "appsettings-inline": `{ + "Credentials": { + "ACCESS_KEY": "superusername", + "SECRET_KEY": "secret" + } + }`, + }, configMap.Data) +} diff --git a/pkg/webhook/object.go b/pkg/webhook/object.go new file mode 100644 index 0000000..50c2aee --- /dev/null +++ b/pkg/webhook/object.go @@ -0,0 +1,141 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "fmt" + "strings" + + "emperror.dev/errors" + "github.com/bank-vaults/internal/injector" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/bank-vaults/secrets-webhook/pkg/common" +) + +type element interface { + Set(v interface{}) + Get() interface{} +} + +type iterator <-chan element + +type mapElement struct { + m map[string]interface{} + k string +} + +func (e *mapElement) Set(v interface{}) { + e.m[e.k] = v +} + +func (e *mapElement) Get() interface{} { + return e.m[e.k] +} + +type sliceElement struct { + s []interface{} + i int +} + +func (e *sliceElement) Set(v interface{}) { + e.s[e.i] = v +} + +func (e *sliceElement) Get() interface{} { + return e.s[e.i] +} + +func mapIterator(m map[string]interface{}) iterator { + c := make(chan element, len(m)) + for k := range m { + c <- &mapElement{k: k, m: m} + } + close(c) + return c +} + +func sliceIterator(s []interface{}) iterator { + c := make(chan element, len(s)) + for i := range s { + c <- &sliceElement{i: i, s: s} + } + close(c) + return c +} + +func traverseObject(o interface{}, secretInjector *injector.SecretInjector) error { + var iterator iterator + + switch value := o.(type) { + case map[string]interface{}: + iterator = mapIterator(value) + case []interface{}: + iterator = sliceIterator(value) + default: + return nil + } + + for e := range iterator { + switch s := e.Get().(type) { + case string: + if common.HasVaultPrefix(s) { + dataFromVault, err := secretInjector.GetDataFromVault(map[string]string{"data": s}) + if err != nil { + return err + } + + e.Set(dataFromVault["data"]) + } else if injector.HasInlineVaultDelimiters(s) { + dataFromVault := s + for _, vaultSecretReference := range injector.FindInlineVaultDelimiters(s) { + mapData, err := secretInjector.GetDataFromVault(map[string]string{"data": vaultSecretReference[1]}) + if err != nil { + return err + } + dataFromVault = strings.Replace(dataFromVault, vaultSecretReference[0], mapData["data"], -1) + } + e.Set(dataFromVault) + } + case map[string]interface{}, []interface{}: + err := traverseObject(e.Get(), secretInjector) + if err != nil { + return err + } + } + } + + return nil +} + +func (mw *MutatingWebhook) MutateObject(object *unstructured.Unstructured, vaultConfig VaultConfig) error { + mw.logger.Debug(fmt.Sprintf("mutating object: %s.%s", object.GetNamespace(), object.GetName())) + + vaultClient, err := mw.newVaultClient(vaultConfig) + if err != nil { + return errors.Wrap(err, "failed to create vault client") + } + + defer vaultClient.Close() + + config := injector.Config{ + TransitKeyID: vaultConfig.TransitKeyID, + TransitPath: vaultConfig.TransitPath, + TransitBatchSize: vaultConfig.TransitBatchSize, + } + secretInjector := injector.NewSecretInjector(config, vaultClient, nil, logger) + + return traverseObject(object.Object, &secretInjector) +} diff --git a/pkg/webhook/pod.go b/pkg/webhook/pod.go new file mode 100644 index 0000000..4640148 --- /dev/null +++ b/pkg/webhook/pod.go @@ -0,0 +1,931 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "emperror.dev/errors" + "github.com/bank-vaults/internal/injector" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubeVer "k8s.io/apimachinery/pkg/version" + + "github.com/bank-vaults/secrets-webhook/pkg/common" +) + +const ( + vaultAgentConfig = ` +pid_file = "/tmp/pidfile" + +auto_auth { + method "kubernetes" { + namespace = "%s" + mount_path = "auth/%s" + config = { + role = "%s" + } + } + + sink "file" { + config = { + path = "/vault/.vault-token" + } + } +}` + SecretInitVolumeName = "secret-init" +) + +func (mw *MutatingWebhook) MutatePod(ctx context.Context, pod *corev1.Pod, webhookConfig Config, secretInitConfig SecretInitConfig, vaultConfig VaultConfig, dryRun bool) error { + mw.logger.Debug("Successfully connected to the API") + + if isPodAlreadyMutated(pod) { + mw.logger.Info(fmt.Sprintf("Pod %s is already mutated, skipping mutation.", pod.Name)) + return nil + } + + initContainersMutated, err := mw.mutateContainers(ctx, pod.Spec.InitContainers, &pod.Spec, webhookConfig, secretInitConfig, vaultConfig) + if err != nil { + return err + } + + if initContainersMutated { + mw.logger.Debug("Successfully mutated pod init containers") + } else { + mw.logger.Debug("No pod init containers were mutated") + } + + containersMutated, err := mw.mutateContainers(ctx, pod.Spec.Containers, &pod.Spec, webhookConfig, secretInitConfig, vaultConfig) + if err != nil { + return err + } + + if containersMutated { + mw.logger.Debug("Successfully mutated pod containers") + } else { + mw.logger.Debug("No pod containers were mutated") + } + + containerEnvVars := []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: vaultConfig.Addr, + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: strconv.FormatBool(vaultConfig.SkipVerify), + }, + } + + if vaultConfig.Token != "" { + containerEnvVars = append(containerEnvVars, corev1.EnvVar{ + Name: "VAULT_TOKEN", + Value: vaultConfig.Token, + }) + } + + containerVolMounts := []corev1.VolumeMount{ + { + Name: SecretInitVolumeName, + MountPath: "/vault/", + }, + } + if vaultConfig.TLSSecret != "" { + mountPath := "/vault/tls/" + volumeName := "vault-tls" + if hasTLSVolume(pod.Spec.Volumes) { + mountPath = "/secret-init/tls/" + volumeName = "secret-init-tls" + } + + containerEnvVars = append(containerEnvVars, corev1.EnvVar{ + Name: "VAULT_CACERT", + Value: mountPath + "ca.crt", + }) + containerVolMounts = append(containerVolMounts, corev1.VolumeMount{ + Name: volumeName, + MountPath: mountPath, + }) + } + + if vaultConfig.CtConfigMap != "" { + mw.logger.Debug("Consul Template config found") + + mw.addSecretsVolToContainers(vaultConfig, pod.Spec.Containers) + + if vaultConfig.CtShareProcessDefault == "empty" { + mw.logger.Debug("Test our Kubernetes API Version and make the final decision on enabling ShareProcessNamespace") + apiVersion, _ := mw.k8sClient.Discovery().ServerVersion() + versionCompared := kubeVer.CompareKubeAwareVersionStrings("v1.12.0", apiVersion.String()) + mw.logger.Debug(fmt.Sprintf("Kubernetes API version detected: %s", apiVersion.String())) + + if versionCompared >= 0 { + vaultConfig.CtShareProcess = true + } else { + vaultConfig.CtShareProcess = false + } + } + + if vaultConfig.CtShareProcess { + mw.logger.Debug("Detected shared process namespace") + shareProcessNamespace := true + pod.Spec.ShareProcessNamespace = &shareProcessNamespace + } + if !vaultConfig.CtOnce { + pod.Spec.Containers = append(getContainers(pod.Spec.SecurityContext, webhookConfig, vaultConfig, containerEnvVars, containerVolMounts), pod.Spec.Containers...) + } else { + if vaultConfig.CtInjectInInitcontainers { + mw.addSecretsVolToContainers(vaultConfig, pod.Spec.InitContainers) + } + pod.Spec.InitContainers = append(getContainers(pod.Spec.SecurityContext, webhookConfig, vaultConfig, containerEnvVars, containerVolMounts), pod.Spec.InitContainers...) + } + + mw.logger.Debug("Successfully appended pod containers to spec") + } + + if initContainersMutated || containersMutated || vaultConfig.CtConfigMap != "" || vaultConfig.AgentConfigMap != "" { + var agentConfigMapName string + + if vaultConfig.UseAgent || vaultConfig.CtConfigMap != "" { + if vaultConfig.AgentConfigMap != "" { + agentConfigMapName = vaultConfig.AgentConfigMap + } else { + configMap := getConfigMapForVaultAgent(pod, vaultConfig) + agentConfigMapName = configMap.Name + if !dryRun { + _, err := mw.k8sClient.CoreV1().ConfigMaps(vaultConfig.ObjectNamespace).Create(context.Background(), configMap, metav1.CreateOptions{}) + if err != nil { + if apierrors.IsAlreadyExists(err) { + _, err = mw.k8sClient.CoreV1().ConfigMaps(vaultConfig.ObjectNamespace).Update(context.Background(), configMap, metav1.UpdateOptions{}) + if err != nil { + return errors.WrapIf(err, "failed to update ConfigMap for config") + } + } else { + return errors.WrapIf(err, "failed to create ConfigMap for config") + } + } + } + } + } + + pod.Spec.InitContainers = append(getInitContainers(pod.Spec.Containers, pod.Spec.SecurityContext, webhookConfig, secretInitConfig, vaultConfig, initContainersMutated, containersMutated, containerEnvVars, containerVolMounts), pod.Spec.InitContainers...) + mw.logger.Debug("Successfully appended pod init containers to spec") + + pod.Spec.Volumes = append(pod.Spec.Volumes, mw.getVolumes(pod.Spec.Volumes, agentConfigMapName, vaultConfig)...) + mw.logger.Debug("Successfully appended pod spec volumes") + } + + if vaultConfig.AgentConfigMap != "" && !vaultConfig.UseAgent { + mw.logger.Debug("Vault Agent config found") + + mw.addAgentSecretsVolToContainers(vaultConfig, pod.Spec.Containers) + + if vaultConfig.AgentShareProcessDefault == "empty" { + mw.logger.Debug("Test our Kubernetes API Version and make the final decision on enabling ShareProcessNamespace") + apiVersion, _ := mw.k8sClient.Discovery().ServerVersion() + versionCompared := kubeVer.CompareKubeAwareVersionStrings("v1.12.0", apiVersion.String()) + mw.logger.Debug(fmt.Sprintf("Kubernetes API version detected: %s", apiVersion.String())) + + if versionCompared >= 0 { + vaultConfig.AgentShareProcess = true + } else { + vaultConfig.AgentShareProcess = false + } + } + + if vaultConfig.AgentShareProcess { + mw.logger.Debug("Detected shared process namespace") + shareProcessNamespace := true + pod.Spec.ShareProcessNamespace = &shareProcessNamespace + } + pod.Spec.Containers = append(getAgentContainers(pod.Spec.Containers, pod.Spec.SecurityContext, webhookConfig, vaultConfig, containerEnvVars, containerVolMounts), pod.Spec.Containers...) + + mw.logger.Debug("Successfully appended pod containers to spec") + } + + return nil +} + +func isPodAlreadyMutated(pod *corev1.Pod) bool { + for _, volume := range pod.Spec.Volumes { + if volume.Name == SecretInitVolumeName { + return true + } + } + return false +} + +func (mw *MutatingWebhook) mutateContainers(ctx context.Context, containers []corev1.Container, podSpec *corev1.PodSpec, webhookConfig Config, secretInitConfig SecretInitConfig, vaultConfig VaultConfig) (bool, error) { + mutated := false + + for i, container := range containers { + var envVars []corev1.EnvVar + if len(container.EnvFrom) > 0 { + envFrom, err := mw.lookForEnvFrom(container.EnvFrom, vaultConfig.ObjectNamespace) + if err != nil { + return false, err + } + envVars = append(envVars, envFrom...) + } + + for _, env := range container.Env { + if common.HasVaultPrefix(env.Value) || injector.HasInlineVaultDelimiters(env.Value) { + envVars = append(envVars, env) + } + if env.ValueFrom != nil { + valueFrom, err := mw.lookForValueFrom(env, vaultConfig.ObjectNamespace) + if err != nil { + return false, err + } + if valueFrom == nil { + continue + } + envVars = append(envVars, *valueFrom) + } + } + + if len(envVars) == 0 && vaultConfig.FromPath == "" { + continue + } + + mutated = true + + args := container.Command + + // the container has no explicitly specified command + if len(args) == 0 { + imageConfig, err := mw.registry.GetImageConfig(ctx, mw.k8sClient, vaultConfig.ObjectNamespace, webhookConfig.RegistrySkipVerify, &container, podSpec) //nolint:gosec + if err != nil { + return false, err + } + + args = append(args, imageConfig.Entrypoint...) + + // If no Args are defined we can use the Docker CMD from the image + // https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#notes + if len(container.Args) == 0 { + args = append(args, imageConfig.Cmd...) + } + } + + args = append(args, container.Args...) + + container.Command = []string{"/vault/secret-init"} + container.Args = args + + // mutate probes if needed + if webhookConfig.MutateProbes { + // mutate LivenessProbe + if container.LivenessProbe != nil && container.LivenessProbe.Exec != nil { + lProbeCmd := container.LivenessProbe.Exec.Command + container.LivenessProbe.Exec.Command = []string{"/vault/secret-init"} + container.LivenessProbe.Exec.Command = append(container.LivenessProbe.Exec.Command, lProbeCmd...) + } + // mutate LivenessProbe + if container.ReadinessProbe != nil && container.ReadinessProbe.Exec != nil { + rProbeCmd := container.ReadinessProbe.Exec.Command + container.ReadinessProbe.Exec.Command = []string{"/vault/secret-init"} + container.ReadinessProbe.Exec.Command = append(container.ReadinessProbe.Exec.Command, rProbeCmd...) + } + } + + container.VolumeMounts = append(container.VolumeMounts, []corev1.VolumeMount{ + { + Name: SecretInitVolumeName, + MountPath: "/vault/", + }, + }...) + + container.Env = append(container.Env, []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: vaultConfig.Addr, + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: strconv.FormatBool(vaultConfig.SkipVerify), + }, + { + Name: "VAULT_AUTH_METHOD", + Value: vaultConfig.AuthMethod, + }, + { + Name: "VAULT_PATH", + Value: vaultConfig.Path, + }, + { + Name: "VAULT_ROLE", + Value: vaultConfig.Role, + }, + { + Name: "VAULT_IGNORE_MISSING_SECRETS", + Value: vaultConfig.IgnoreMissingSecrets, + }, + { + Name: "VAULT_PASSTHROUGH", + Value: vaultConfig.Passthrough, + }, + { + Name: "SECRET_INIT_JSON_LOG", + Value: secretInitConfig.JSONLog, + }, + { + Name: "VAULT_CLIENT_TIMEOUT", + Value: vaultConfig.ClientTimeout.String(), + }, + }...) + + if vaultConfig.Token != "" { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "VAULT_TOKEN", + Value: vaultConfig.Token, + }) + } + + if !isLogLevelSet(container.Env) && secretInitConfig.LogLevel != "" { + container.Env = append(container.Env, []corev1.EnvVar{ + { + Name: "SECRET_INIT_LOG_LEVEL", + Value: secretInitConfig.LogLevel, + }, + }...) + } + + if len(vaultConfig.TransitKeyID) > 0 { + container.Env = append(container.Env, []corev1.EnvVar{ + { + Name: "VAULT_TRANSIT_KEY_ID", + Value: vaultConfig.TransitKeyID, + }, + }...) + } + + if len(vaultConfig.TransitPath) > 0 { + container.Env = append(container.Env, []corev1.EnvVar{ + { + Name: "VAULT_TRANSIT_PATH", + Value: vaultConfig.TransitPath, + }, + }...) + } + + if vaultConfig.TransitBatchSize > 0 { + container.Env = append(container.Env, []corev1.EnvVar{ + { + Name: "VAULT_TRANSIT_BATCH_SIZE", + Value: strconv.Itoa(vaultConfig.TransitBatchSize), + }, + }...) + } + + if len(vaultConfig.VaultNamespace) > 0 { + container.Env = append(container.Env, []corev1.EnvVar{ + { + Name: "VAULT_NAMESPACE", + Value: vaultConfig.VaultNamespace, + }, + }...) + } + + if vaultConfig.TLSSecret != "" { + mountPath := "/vault/tls/" + volumeName := "vault-tls" + if hasTLSVolume(podSpec.Volumes) { + mountPath = "/secret-init/tls/" + volumeName = "secret-init-tls" + } + + container.Env = append(container.Env, corev1.EnvVar{ + Name: "VAULT_CACERT", + Value: mountPath + "ca.crt", + }) + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: volumeName, + MountPath: mountPath, + }) + } + + if vaultConfig.UseAgent || vaultConfig.TokenAuthMount != "" { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "VAULT_TOKEN_FILE", + Value: "/vault/.vault-token", + }) + } + + if secretInitConfig.Daemon { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "SECRET_INIT_DAEMON", + Value: "true", + }) + } + + if secretInitConfig.Delay > 0 { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "SECRET_INIT_DELAY", + Value: secretInitConfig.Delay.String(), + }) + } + + if vaultConfig.FromPath != "" { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "VAULT_FROM_PATH", + Value: vaultConfig.FromPath, + }) + } + + if secretInitConfig.LogServer != "" { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "SECRET_INIT_LOG_SERVER", + Value: secretInitConfig.LogServer, + }) + } + + containers[i] = container + } + + return mutated, nil +} + +func (mw *MutatingWebhook) addSecretsVolToContainers(vaultConfig VaultConfig, containers []corev1.Container) { + for i, container := range containers { + mw.logger.Debug(fmt.Sprintf("Add secrets VolumeMount to container %s", container.Name)) + + container.VolumeMounts = append(container.VolumeMounts, []corev1.VolumeMount{ + { + Name: "ct-secrets", + MountPath: vaultConfig.ConfigfilePath, + }, + }...) + + containers[i] = container + } +} + +func (mw *MutatingWebhook) addAgentSecretsVolToContainers(vaultConfig VaultConfig, containers []corev1.Container) { + for i, container := range containers { + mw.logger.Debug(fmt.Sprintf("Add secrets VolumeMount to container %s", container.Name)) + + container.VolumeMounts = append(container.VolumeMounts, []corev1.VolumeMount{ + { + Name: "agent-secrets", + MountPath: vaultConfig.ConfigfilePath, + }, + }...) + + containers[i] = container + } +} + +func (mw *MutatingWebhook) getVolumes(existingVolumes []corev1.Volume, agentConfigMapName string, vaultConfig VaultConfig) []corev1.Volume { + mw.logger.Debug("Add generic volumes to podspec") + + volumes := []corev1.Volume{ + { + Name: SecretInitVolumeName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + } + + if vaultConfig.UseAgent || vaultConfig.CtConfigMap != "" { + mw.logger.Debug("Add vault agent volumes to podspec") + volumes = append(volumes, corev1.Volume{ + Name: "vault-agent-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: agentConfigMapName, + }, + }, + }, + }) + } + + if vaultConfig.TLSSecret != "" { + mw.logger.Debug("Add vault TLS volume to podspec") + + volumeName := "vault-tls" + if hasTLSVolume(existingVolumes) { + volumeName = "secret-init-tls" + } + + volumes = append(volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: vaultConfig.TLSSecret, + }, + Items: []corev1.KeyToPath{{ + Key: "ca.crt", + Path: "ca.crt", + }}, + }, + }}, + }, + }, + }) + } + if vaultConfig.CtConfigMap != "" { + mw.logger.Debug("Add consul template volumes to podspec") + + defaultMode := int32(420) + volumes = append(volumes, + corev1.Volume{ + Name: "ct-secrets", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + corev1.Volume{ + Name: "ct-configmap", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: vaultConfig.CtConfigMap, + }, + DefaultMode: &defaultMode, + Items: []corev1.KeyToPath{ + { + Key: "config.hcl", + Path: "config.hcl", + }, + }, + }, + }, + }) + } + + if vaultConfig.AgentConfigMap != "" { + mw.logger.Debug("Add vault-agent volumes to podspec") + + defaultMode := int32(420) + volumes = append(volumes, + corev1.Volume{ + Name: "agent-secrets", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + corev1.Volume{ + Name: "agent-configmap", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: vaultConfig.AgentConfigMap, + }, + DefaultMode: &defaultMode, + Items: []corev1.KeyToPath{ + { + Key: "config.hcl", + Path: "config.hcl", + }, + }, + }, + }, + }) + } + + return volumes +} + +// If the original Pod contained a Volume "vault-tls", for example Vault instances provisioned by the Operator +// we need to handle that edge case and choose another name for the vault-tls volume for accessing Vault with TLS. +func hasTLSVolume(volumes []corev1.Volume) bool { + for _, volume := range volumes { + if volume.Name == "vault-tls" { + return true + } + } + return false +} + +func getServiceAccountMount(containers []corev1.Container, vaultConfig VaultConfig) (serviceAccountMount corev1.VolumeMount) { +mountSearch: + for _, container := range containers { + for _, mount := range container.VolumeMounts { + if mount.MountPath == vaultConfig.ServiceAccountTokenVolumeName { + serviceAccountMount = mount + + break mountSearch + } + } + } + return serviceAccountMount +} + +func getInitContainers(originalContainers []corev1.Container, podSecurityContext *corev1.PodSecurityContext, webhookConfig Config, secretInitConfig SecretInitConfig, vaultConfig VaultConfig, initContainersMutated bool, containersMutated bool, containerEnvVars []corev1.EnvVar, containerVolMounts []corev1.VolumeMount) []corev1.Container { + containers := []corev1.Container{} + + if vaultConfig.TokenAuthMount != "" { + // vault.security.banzaicloud.io/token-auth-mount: "token:vault-token" + split := strings.Split(vaultConfig.TokenAuthMount, ":") + mountName := split[0] + tokenName := split[1] + fileLoc := "/token/" + tokenName + cmd := fmt.Sprintf("cp %s /vault/.vault-token", fileLoc) + + containers = append(containers, corev1.Container{ + Name: "copy-vault-token", + Image: vaultConfig.AgentImage, + ImagePullPolicy: vaultConfig.AgentImagePullPolicy, + Command: []string{"sh", "-c", cmd}, + SecurityContext: getBaseSecurityContext(podSecurityContext, webhookConfig), + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: SecretInitVolumeName, + MountPath: "/vault/", + }, + { + Name: mountName, + MountPath: "/token", + }, + }, + }) + } else if vaultConfig.Token == "" && (vaultConfig.UseAgent || vaultConfig.CtConfigMap != "") { + serviceAccountMount := getServiceAccountMount(originalContainers, vaultConfig) + + containerVolMounts = append(containerVolMounts, serviceAccountMount, corev1.VolumeMount{ + Name: "vault-agent-config", + MountPath: "/vault/agent/", + }) + + securityContext := getBaseSecurityContext(podSecurityContext, webhookConfig) + securityContext.Capabilities.Add = []corev1.Capability{ + "CHOWN", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + } + + containers = append(containers, corev1.Container{ + Name: "vault-agent", + Image: vaultConfig.AgentImage, + ImagePullPolicy: vaultConfig.AgentImagePullPolicy, + SecurityContext: securityContext, + Command: []string{"vault", "agent", "-config=/vault/agent/config.hcl", "-exit-after-auth"}, + Env: containerEnvVars, + VolumeMounts: containerVolMounts, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: secretInitConfig.CPULimit, + corev1.ResourceMemory: secretInitConfig.MemoryLimit, + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: secretInitConfig.CPURequest, + corev1.ResourceMemory: secretInitConfig.MemoryRequest, + }, + }, + }) + } + + if initContainersMutated || containersMutated { + containers = append(containers, corev1.Container{ + Name: "copy-secret-init", + Image: secretInitConfig.Image, + ImagePullPolicy: secretInitConfig.ImagePullPolicy, + Command: []string{"sh", "-c", "cp /usr/local/bin/secret-init /vault/"}, + VolumeMounts: []corev1.VolumeMount{ + { + Name: SecretInitVolumeName, + MountPath: "/vault/", + }, + }, + + SecurityContext: getBaseSecurityContext(podSecurityContext, webhookConfig), + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: secretInitConfig.CPULimit, + corev1.ResourceMemory: secretInitConfig.MemoryLimit, + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: secretInitConfig.CPURequest, + corev1.ResourceMemory: secretInitConfig.MemoryRequest, + }, + }, + }) + } + + return containers +} + +func getContainers(podSecurityContext *corev1.PodSecurityContext, webhookConfig Config, vaultConfig VaultConfig, containerEnvVars []corev1.EnvVar, containerVolMounts []corev1.VolumeMount) []corev1.Container { + containers := []corev1.Container{} + securityContext := getBaseSecurityContext(podSecurityContext, webhookConfig) + + if vaultConfig.CtShareProcess { + securityContext.Capabilities.Add = append(securityContext.Capabilities.Add, "SYS_PTRACE") + } + + containerVolMounts = append(containerVolMounts, corev1.VolumeMount{ + Name: "ct-secrets", + MountPath: vaultConfig.ConfigfilePath, + }, corev1.VolumeMount{ + Name: SecretInitVolumeName, + MountPath: "/home/consul-template", + }, corev1.VolumeMount{ + Name: "ct-configmap", + MountPath: "/vault/ct-config/config.hcl", + ReadOnly: true, + SubPath: "config.hcl", + }, + ) + + var ctCommandString []string + if vaultConfig.CtOnce { + ctCommandString = []string{"-config", "/vault/ct-config/config.hcl", "-once"} + } else { + ctCommandString = []string{"-config", "/vault/ct-config/config.hcl"} + } + + containers = append(containers, corev1.Container{ + Name: "consul-template", + Image: vaultConfig.CtImage, + Args: ctCommandString, + ImagePullPolicy: vaultConfig.CtImagePullPolicy, + SecurityContext: securityContext, + Env: containerEnvVars, + VolumeMounts: containerVolMounts, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: vaultConfig.CtCPU, + corev1.ResourceMemory: vaultConfig.CtMemory, + }, + }, + }) + + return containers +} + +func getAgentContainers(originalContainers []corev1.Container, podSecurityContext *corev1.PodSecurityContext, webhookConfig Config, vaultConfig VaultConfig, containerEnvVars []corev1.EnvVar, containerVolMounts []corev1.VolumeMount) []corev1.Container { + containers := []corev1.Container{} + + securityContext := getBaseSecurityContext(podSecurityContext, webhookConfig) + securityContext.Capabilities.Add = []corev1.Capability{ + "CHOWN", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + "IPC_LOCK", + } + + if vaultConfig.AgentShareProcess { + securityContext.Capabilities.Add = append(securityContext.Capabilities.Add, "SYS_PTRACE") + } + + serviceAccountMount := getServiceAccountMount(originalContainers, vaultConfig) + + containerVolMounts = append(containerVolMounts, serviceAccountMount, corev1.VolumeMount{ + Name: "agent-secrets", + MountPath: vaultConfig.ConfigfilePath, + }, corev1.VolumeMount{ + Name: "agent-configmap", + MountPath: "/vault/config/config.hcl", + ReadOnly: true, + SubPath: "config.hcl", + }, + ) + + var agentCommandString []string + if vaultConfig.AgentOnce { + agentCommandString = []string{"agent", "-config", "/vault/config/config.hcl", "-exit-after-auth"} + } else { + agentCommandString = []string{"agent", "-config", "/vault/config/config.hcl"} + } + + if vaultConfig.AgentEnvVariables != "" { + var envVars []corev1.EnvVar + err := json.Unmarshal([]byte(vaultConfig.AgentEnvVariables), &envVars) + if err != nil { + envVars = []corev1.EnvVar{} + } + containerEnvVars = append(containerEnvVars, envVars...) + } + + containers = append(containers, corev1.Container{ + Name: "vault-agent", + Image: vaultConfig.AgentImage, + Args: agentCommandString, + ImagePullPolicy: vaultConfig.AgentImagePullPolicy, + SecurityContext: securityContext, + Env: containerEnvVars, + VolumeMounts: containerVolMounts, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: vaultConfig.AgentCPULimit, + corev1.ResourceMemory: vaultConfig.AgentMemoryLimit, + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: vaultConfig.AgentCPURequest, + corev1.ResourceMemory: vaultConfig.AgentMemoryRequest, + }, + }, + }) + + return containers +} + +func getBaseSecurityContext(podSecurityContext *corev1.PodSecurityContext, webhookConfig Config) *corev1.SecurityContext { + context := &corev1.SecurityContext{ + AllowPrivilegeEscalation: &webhookConfig.PspAllowPrivilegeEscalation, + ReadOnlyRootFilesystem: &webhookConfig.ReadOnlyRootFilesystem, + Capabilities: &corev1.Capabilities{ + Add: []corev1.Capability{}, + Drop: []corev1.Capability{ + "ALL", + }, + }, + } + + if podSecurityContext != nil && podSecurityContext.RunAsUser != nil { + context.RunAsUser = podSecurityContext.RunAsUser + } + + // Although it could explicitly be set to false, + // the behavior of false and unset are the same + if webhookConfig.RunAsNonRoot { + context.RunAsNonRoot = &webhookConfig.RunAsNonRoot + } + + if webhookConfig.RunAsUser > 0 { + context.RunAsUser = &webhookConfig.RunAsUser + } + + if webhookConfig.RunAsGroup > 0 { + context.RunAsGroup = &webhookConfig.RunAsGroup + } + + return context +} + +func getConfigMapForVaultAgent(pod *corev1.Pod, vaultConfig VaultConfig) *corev1.ConfigMap { + ownerReferences := pod.GetOwnerReferences() + name := pod.GetName() + // If we have no name we are probably part of some controller, + // try to get the name of the owner controller. + if name == "" { + if len(ownerReferences) > 0 { + if strings.Contains(ownerReferences[0].Name, "-") { + generateNameSlice := strings.Split(ownerReferences[0].Name, "-") + name = strings.Join(generateNameSlice[:len(generateNameSlice)-1], "-") + } else { + name = ownerReferences[0].Name + } + } + } + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-vault-agent-config", + OwnerReferences: ownerReferences, + }, + Data: map[string]string{ + "config.hcl": fmt.Sprintf(vaultAgentConfig, vaultConfig.VaultNamespace, vaultConfig.Path, vaultConfig.Role), + }, + } +} + +// isLogLevelSet checks if the SECRET_INIT_LOG_LEVEL environment variable +// has already been set in the container, so it doesn't get overridden. +func isLogLevelSet(envVars []corev1.EnvVar) bool { + for _, envVar := range envVars { + if envVar.Name == "SECRET_INIT_LOG_LEVEL" { + return true + } + } + return false +} diff --git a/pkg/webhook/pod_test.go b/pkg/webhook/pod_test.go new file mode 100644 index 0000000..a512042 --- /dev/null +++ b/pkg/webhook/pod_test.go @@ -0,0 +1,1697 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "log/slog" + "testing" + "time" + + cmp "github.com/google/go-cmp/cmp" + v1 "github.com/google/go-containerregistry/pkg/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/client-go/kubernetes" + fake "k8s.io/client-go/kubernetes/fake" +) + +var webhookConfig = Config{ + RunAsNonRoot: true, + RunAsUser: int64(1000), + RunAsGroup: int64(1000), +} + +var secretInitConfig = SecretInitConfig{ + JSONLog: "enableJSONLog", +} + +var vaultConfig = VaultConfig{ + Addr: "addr", + SkipVerify: false, + Path: "path", + Role: "role", + AuthMethod: "jwt", + IgnoreMissingSecrets: "ignoreMissingSecrets", + Passthrough: "vaultPassthrough", + ClientTimeout: 10 * time.Second, +} + +type MockRegistry struct { + Image v1.Config +} + +func (r *MockRegistry) GetImageConfig(_ context.Context, _ kubernetes.Interface, _ string, _ bool, _ *corev1.Container, _ *corev1.PodSpec) (*v1.Config, error) { + return &r.Image, nil +} + +func Test_mutatingWebhook_mutateContainers(t *testing.T) { + t.Parallel() + + vaultConfigEnvFrom := vaultConfig + vaultConfigEnvFrom.FromPath = "secrets/application" + + type fields struct { + k8sClient kubernetes.Interface + registry ImageRegistry + } + type args struct { + containers []corev1.Container + podSpec *corev1.PodSpec + webhookConfig Config + SecretInitConfig SecretInitConfig + vaultConfig VaultConfig + } + tests := []struct { + name string + fields fields + args args + mutated bool + wantErr bool + wantedContainers []corev1.Container + }{ + { + name: "Will mutate container with command, no args", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: "vault:secrets", + }, + }, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: secretInitConfig, + vaultConfig: vaultConfig, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"/bin/bash"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: "vault:secrets"}, + {Name: "VAULT_ADDR", Value: "addr"}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: "jwt"}, + {Name: "VAULT_PATH", Value: "path"}, + {Name: "VAULT_ROLE", Value: "role"}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: "ignoreMissingSecrets"}, + {Name: "VAULT_PASSTHROUGH", Value: "vaultPassthrough"}, + {Name: "SECRET_INIT_JSON_LOG", Value: "enableJSONLog"}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "10s"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + { + name: "Will mutate container with command, other syntax", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: ">>vault:secrets", + }, + }, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: secretInitConfig, + vaultConfig: vaultConfig, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"/bin/bash"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: ">>vault:secrets"}, + {Name: "VAULT_ADDR", Value: "addr"}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: "jwt"}, + {Name: "VAULT_PATH", Value: "path"}, + {Name: "VAULT_ROLE", Value: "role"}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: "ignoreMissingSecrets"}, + {Name: "VAULT_PASSTHROUGH", Value: "vaultPassthrough"}, + {Name: "SECRET_INIT_JSON_LOG", Value: "enableJSONLog"}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "10s"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + { + name: "Will mutate container with args, no command", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{ + Entrypoint: []string{"myEntryPoint"}, + }, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{}, + Args: nil, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: ">>vault:secrets", + }, + }, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: secretInitConfig, + vaultConfig: vaultConfig, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"myEntryPoint"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: ">>vault:secrets"}, + {Name: "VAULT_ADDR", Value: "addr"}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: "jwt"}, + {Name: "VAULT_PATH", Value: "path"}, + {Name: "VAULT_ROLE", Value: "role"}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: "ignoreMissingSecrets"}, + {Name: "VAULT_PASSTHROUGH", Value: "vaultPassthrough"}, + {Name: "SECRET_INIT_JSON_LOG", Value: "enableJSONLog"}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "10s"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + { + name: "Will mutate container with probes", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: []string{"/bin/bash"}, + }, + }, + }, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: "vault:secrets", + }, + }, + }, + }, + webhookConfig: Config{ + MutateProbes: true, + }, + SecretInitConfig: SecretInitConfig{}, + vaultConfig: VaultConfig{}, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"/bin/bash"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + Exec: &corev1.ExecAction{ + Command: []string{"/vault/secret-init", "/bin/bash"}, + }, + }, + }, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: "vault:secrets"}, + {Name: "VAULT_ADDR", Value: ""}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: ""}, + {Name: "VAULT_PATH", Value: ""}, + {Name: "VAULT_ROLE", Value: ""}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: ""}, + {Name: "VAULT_PASSTHROUGH", Value: ""}, + {Name: "SECRET_INIT_JSON_LOG", Value: ""}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "0s"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + { + name: "Will mutate container with no container-command, no entrypoint", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{ + Cmd: []string{"myCmd"}, + }, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{}, + Args: nil, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: ">>vault:secrets", + }, + }, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: secretInitConfig, + vaultConfig: vaultConfig, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"myCmd"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: ">>vault:secrets"}, + {Name: "VAULT_ADDR", Value: "addr"}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: "jwt"}, + {Name: "VAULT_PATH", Value: "path"}, + {Name: "VAULT_ROLE", Value: "role"}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: "ignoreMissingSecrets"}, + {Name: "VAULT_PASSTHROUGH", Value: "vaultPassthrough"}, + {Name: "SECRET_INIT_JSON_LOG", Value: "enableJSONLog"}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "10s"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + { + name: "Will not mutate container without secrets with correct prefix", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: secretInitConfig, + vaultConfig: vaultConfig, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + }, + }, + mutated: false, + wantErr: false, + }, + { + name: "Will mutate container with env-from-path annotation", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: "vault:secrets", + }, + }, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: secretInitConfig, + vaultConfig: vaultConfigEnvFrom, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"/bin/bash"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: "vault:secrets"}, + {Name: "VAULT_ADDR", Value: "addr"}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: "jwt"}, + {Name: "VAULT_PATH", Value: "path"}, + {Name: "VAULT_ROLE", Value: "role"}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: "ignoreMissingSecrets"}, + {Name: "VAULT_PASSTHROUGH", Value: "vaultPassthrough"}, + {Name: "SECRET_INIT_JSON_LOG", Value: "enableJSONLog"}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "10s"}, + {Name: "VAULT_FROM_PATH", Value: "secrets/application"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + { + name: "Will mutate container with command, no args, with inline mutation", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: "scheme://${vault:secret/data/account#username}:${vault:secret/data/account#password}@127.0.0.1:8080", + }, + }, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: secretInitConfig, + vaultConfig: vaultConfig, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"/bin/bash"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: "scheme://${vault:secret/data/account#username}:${vault:secret/data/account#password}@127.0.0.1:8080"}, + {Name: "VAULT_ADDR", Value: "addr"}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: "jwt"}, + {Name: "VAULT_PATH", Value: "path"}, + {Name: "VAULT_ROLE", Value: "role"}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: "ignoreMissingSecrets"}, + {Name: "VAULT_PASSTHROUGH", Value: "vaultPassthrough"}, + {Name: "SECRET_INIT_JSON_LOG", Value: "enableJSONLog"}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "10s"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + { + name: "Mutate will not change the containers log level if it was already set", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + Env: []corev1.EnvVar{ + { + Name: "myvar", + Value: "vault:secrets", + }, + { + Name: "SECRET_INIT_LOG_LEVEL", + Value: "info", + }, + }, + }, + }, + webhookConfig: webhookConfig, + SecretInitConfig: SecretInitConfig{ + JSONLog: "enableJSONLog", + LogLevel: "debug", + }, + vaultConfig: VaultConfig{ + Addr: "addr", + SkipVerify: false, + Path: "path", + Role: "role", + AuthMethod: "jwt", + IgnoreMissingSecrets: "ignoreMissingSecrets", + Passthrough: "vaultPassthrough", + ClientTimeout: 10 * time.Second, + }, + }, + wantedContainers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/vault/secret-init"}, + Args: []string{"/bin/bash"}, + VolumeMounts: []corev1.VolumeMount{{Name: "secret-init", MountPath: "/vault/"}}, + Env: []corev1.EnvVar{ + {Name: "myvar", Value: "vault:secrets"}, + {Name: "SECRET_INIT_LOG_LEVEL", Value: "info"}, + {Name: "VAULT_ADDR", Value: "addr"}, + {Name: "VAULT_SKIP_VERIFY", Value: "false"}, + {Name: "VAULT_AUTH_METHOD", Value: "jwt"}, + {Name: "VAULT_PATH", Value: "path"}, + {Name: "VAULT_ROLE", Value: "role"}, + {Name: "VAULT_IGNORE_MISSING_SECRETS", Value: "ignoreMissingSecrets"}, + {Name: "VAULT_PASSTHROUGH", Value: "vaultPassthrough"}, + {Name: "SECRET_INIT_JSON_LOG", Value: "enableJSONLog"}, + {Name: "VAULT_CLIENT_TIMEOUT", Value: "10s"}, + }, + }, + }, + mutated: true, + wantErr: false, + }, + } + + for _, tt := range tests { + ttp := tt + t.Run(ttp.name, func(t *testing.T) { + t.Parallel() + + mw := &MutatingWebhook{ + k8sClient: ttp.fields.k8sClient, + registry: ttp.fields.registry, + logger: slog.Default(), + } + got, err := mw.mutateContainers(context.Background(), ttp.args.containers, ttp.args.podSpec, ttp.args.webhookConfig, ttp.args.SecretInitConfig, ttp.args.vaultConfig) + if (err != nil) != ttp.wantErr { + t.Errorf("MutatingWebhook.mutateContainers() error = %v, wantErr %v", err, ttp.wantErr) + return + } + if got != ttp.mutated { + t.Errorf("MutatingWebhook.mutateContainers() = %v, want %v", got, ttp.mutated) + } + if !cmp.Equal(ttp.args.containers, ttp.wantedContainers) { + t.Errorf("MutatingWebhook.mutateContainers() = diff %v", cmp.Diff(ttp.args.containers, ttp.wantedContainers)) + } + }) + } +} + +func Test_mutatingWebhook_mutatePod(t *testing.T) { + t.Parallel() + + type fields struct { + k8sClient kubernetes.Interface + registry ImageRegistry + } + type args struct { + pod *corev1.Pod + webhookConfig Config + secretInitConfig SecretInitConfig + vaultConfig VaultConfig + } + + defaultMode := int32(420) + + baseSecurityContext := &corev1.SecurityContext{ + RunAsUser: &webhookConfig.RunAsUser, + RunAsGroup: &webhookConfig.RunAsGroup, + RunAsNonRoot: &webhookConfig.RunAsNonRoot, + ReadOnlyRootFilesystem: &webhookConfig.ReadOnlyRootFilesystem, + AllowPrivilegeEscalation: &webhookConfig.PspAllowPrivilegeEscalation, + Capabilities: &corev1.Capabilities{ + Add: []corev1.Capability{}, + Drop: []corev1.Capability{ + "ALL", + }, + }, + } + + agentInitContainerSecurityContext := &corev1.SecurityContext{ + RunAsUser: &webhookConfig.RunAsUser, + RunAsGroup: &webhookConfig.RunAsGroup, + RunAsNonRoot: &webhookConfig.RunAsNonRoot, + ReadOnlyRootFilesystem: &webhookConfig.ReadOnlyRootFilesystem, + AllowPrivilegeEscalation: &webhookConfig.PspAllowPrivilegeEscalation, + Capabilities: &corev1.Capabilities{ + Add: []corev1.Capability{ + "CHOWN", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + }, + Drop: []corev1.Capability{ + "ALL", + }, + }, + } + + agentContainerSecurityContext := &corev1.SecurityContext{ + RunAsUser: &webhookConfig.RunAsUser, + RunAsGroup: &webhookConfig.RunAsGroup, + RunAsNonRoot: &webhookConfig.RunAsNonRoot, + ReadOnlyRootFilesystem: &webhookConfig.ReadOnlyRootFilesystem, + AllowPrivilegeEscalation: &webhookConfig.PspAllowPrivilegeEscalation, + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{ + "ALL", + }, + Add: []corev1.Capability{ + "CHOWN", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + "IPC_LOCK", + }, + }, + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + wantedPod *corev1.Pod + }{ + { + name: "Will mutate pod with ct-configmap annotations", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + pod: &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + }, + }, + }, + }, + webhookConfig: Config{ + RunAsNonRoot: true, + RunAsUser: int64(1000), + RunAsGroup: int64(1000), + }, + secretInitConfig: SecretInitConfig{ + CPURequest: resource.MustParse("50m"), + MemoryRequest: resource.MustParse("64Mi"), + CPULimit: resource.MustParse("250m"), + MemoryLimit: resource.MustParse("64Mi"), + }, + vaultConfig: VaultConfig{ + CtConfigMap: "config-map-test", + ConfigfilePath: "/vault/secrets", + Addr: "test", + SkipVerify: false, + CtCPU: resource.MustParse("50m"), + CtMemory: resource.MustParse("128Mi"), + AgentImage: "hashicorp/vault:latest", + AgentImagePullPolicy: "IfNotPresent", + ServiceAccountTokenVolumeName: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + wantedPod: &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "vault-agent", + Image: "hashicorp/vault:latest", + Command: []string{"vault", "agent", "-config=/vault/agent/config.hcl", "-exit-after-auth"}, + ImagePullPolicy: "IfNotPresent", + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + }, + SecurityContext: agentInitContainerSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "vault-agent-config", + MountPath: "/vault/agent/", + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "consul-template", + Args: []string{"-config", "/vault/ct-config/config.hcl"}, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + SecurityContext: baseSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + { + Name: "secret-init", + MountPath: "/home/consul-template", + }, + { + Name: "ct-configmap", + ReadOnly: true, + MountPath: "/vault/ct-config/config.hcl", + SubPath: "config.hcl", + }, + }, + }, + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "secret-init", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "vault-agent-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "-vault-agent-config", + }, + }, + }, + }, + { + Name: "ct-secrets", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "ct-configmap", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "config-map-test", + }, + Items: []corev1.KeyToPath{ + { + Key: "config.hcl", + Path: "config.hcl", + }, + }, + DefaultMode: &defaultMode, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "Will mutate pod with ct-once annotations", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + pod: &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + }, + }, + }, + }, + webhookConfig: Config{ + RunAsNonRoot: true, + RunAsUser: int64(1000), + RunAsGroup: int64(1000), + }, + secretInitConfig: SecretInitConfig{ + CPURequest: resource.MustParse("50m"), + MemoryRequest: resource.MustParse("64Mi"), + CPULimit: resource.MustParse("250m"), + MemoryLimit: resource.MustParse("64Mi"), + }, + vaultConfig: VaultConfig{ + CtConfigMap: "config-map-test", + CtOnce: true, + ConfigfilePath: "/vault/secrets", + Addr: "test", + SkipVerify: false, + CtCPU: resource.MustParse("50m"), + CtMemory: resource.MustParse("128Mi"), + AgentImage: "hashicorp/vault:latest", + AgentImagePullPolicy: "IfNotPresent", + ServiceAccountTokenVolumeName: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + wantedPod: &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "vault-agent", + Image: "hashicorp/vault:latest", + Command: []string{"vault", "agent", "-config=/vault/agent/config.hcl", "-exit-after-auth"}, + ImagePullPolicy: "IfNotPresent", + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + }, + SecurityContext: agentInitContainerSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "vault-agent-config", + MountPath: "/vault/agent/", + }, + }, + }, + { + Name: "consul-template", + Args: []string{"-config", "/vault/ct-config/config.hcl", "-once"}, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + SecurityContext: baseSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + { + Name: "secret-init", + MountPath: "/home/consul-template", + }, + { + Name: "ct-configmap", + ReadOnly: true, + MountPath: "/vault/ct-config/config.hcl", + SubPath: "config.hcl", + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "secret-init", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "vault-agent-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "-vault-agent-config", + }, + }, + }, + }, + { + Name: "ct-secrets", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "ct-configmap", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "config-map-test", + }, + Items: []corev1.KeyToPath{ + { + Key: "config.hcl", + Path: "config.hcl", + }, + }, + DefaultMode: &defaultMode, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "Will mutate pod with agent-configmap annotations and envVariables", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + pod: &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + }, + }, + }, + }, + webhookConfig: Config{ + RunAsNonRoot: true, + RunAsUser: int64(1000), + RunAsGroup: int64(1000), + }, + vaultConfig: VaultConfig{ + AgentConfigMap: "config-map-test", + ConfigfilePath: "/vault/secrets", + Addr: "test", + SkipVerify: false, + AgentCPURequest: resource.MustParse("200m"), + AgentMemoryRequest: resource.MustParse("256Mi"), + AgentCPULimit: resource.MustParse("500m"), + AgentMemoryLimit: resource.MustParse("384Mi"), + AgentImage: "hashicorp/vault:latest", + AgentImagePullPolicy: "IfNotPresent", + ServiceAccountTokenVolumeName: "/var/run/secrets/kubernetes.io/serviceaccount", + AgentEnvVariables: "[{\"Name\": \"SKIP_SETCAP\",\"Value\": \"1\"}]", + }, + }, + wantedPod: &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + Containers: []corev1.Container{ + { + Name: "vault-agent", + Image: "hashicorp/vault:latest", + ImagePullPolicy: "IfNotPresent", + Args: []string{"agent", "-config", "/vault/config/config.hcl"}, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("384Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + }, + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + { + Name: "SKIP_SETCAP", + Value: "1", + }, + }, + SecurityContext: agentContainerSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "agent-secrets", + MountPath: "/vault/secrets", + }, + { + Name: "agent-configmap", + ReadOnly: true, + MountPath: "/vault/config/config.hcl", + SubPath: "config.hcl", + }, + }, + }, + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "agent-secrets", + MountPath: "/vault/secrets", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "secret-init", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "agent-secrets", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "agent-configmap", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "config-map-test", + }, + Items: []corev1.KeyToPath{ + { + Key: "config.hcl", + Path: "config.hcl", + }, + }, + DefaultMode: &defaultMode, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "Will mutate pod with vault-ct-inject-in-initcontainers and ct-once annotations", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + pod: &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "MyInitContainer", + Image: "myInitimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + }, + }, + }, + }, + webhookConfig: Config{ + RunAsNonRoot: true, + RunAsUser: int64(1000), + RunAsGroup: int64(1000), + }, + secretInitConfig: SecretInitConfig{ + CPURequest: resource.MustParse("50m"), + MemoryRequest: resource.MustParse("64Mi"), + CPULimit: resource.MustParse("250m"), + MemoryLimit: resource.MustParse("64Mi"), + }, + vaultConfig: VaultConfig{ + CtConfigMap: "config-map-test", + CtOnce: true, + CtInjectInInitcontainers: true, + ConfigfilePath: "/vault/secrets", + Addr: "test", + SkipVerify: false, + CtCPU: resource.MustParse("50m"), + CtMemory: resource.MustParse("128Mi"), + AgentImage: "hashicorp/vault:latest", + AgentImagePullPolicy: "IfNotPresent", + ServiceAccountTokenVolumeName: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + wantedPod: &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "vault-agent", + Image: "hashicorp/vault:latest", + Command: []string{"vault", "agent", "-config=/vault/agent/config.hcl", "-exit-after-auth"}, + ImagePullPolicy: "IfNotPresent", + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + }, + SecurityContext: agentInitContainerSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "vault-agent-config", + MountPath: "/vault/agent/", + }, + }, + }, + { + Name: "consul-template", + Args: []string{"-config", "/vault/ct-config/config.hcl", "-once"}, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + SecurityContext: baseSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + { + Name: "secret-init", + MountPath: "/home/consul-template", + }, + { + Name: "ct-configmap", + ReadOnly: true, + MountPath: "/vault/ct-config/config.hcl", + SubPath: "config.hcl", + }, + }, + }, + { + Name: "MyInitContainer", + Image: "myInitimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + {Name: "ct-secrets", MountPath: "/vault/secrets"}, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "secret-init", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "vault-agent-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "-vault-agent-config", + }, + }, + }, + }, + { + Name: "ct-secrets", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "ct-configmap", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "config-map-test", + }, + Items: []corev1.KeyToPath{ + { + Key: "config.hcl", + Path: "config.hcl", + }, + }, + DefaultMode: &defaultMode, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "Will mutate pod with vault-ct-inject-in-initcontainers and without ct-once annotations", + fields: fields{ + k8sClient: fake.NewSimpleClientset(), + registry: &MockRegistry{ + Image: v1.Config{}, + }, + }, + args: args{ + pod: &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "MyInitContainer", + Image: "myInitimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/vault", + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/vault", + }, + }, + }, + }, + }, + }, + webhookConfig: Config{ + RunAsNonRoot: true, + RunAsUser: int64(1000), + RunAsGroup: int64(1000), + }, + secretInitConfig: SecretInitConfig{ + CPURequest: resource.MustParse("50m"), + MemoryRequest: resource.MustParse("64Mi"), + CPULimit: resource.MustParse("250m"), + MemoryLimit: resource.MustParse("64Mi"), + }, + vaultConfig: VaultConfig{ + CtConfigMap: "config-map-test", + CtInjectInInitcontainers: true, + ConfigfilePath: "/vault/secrets", + Addr: "test", + SkipVerify: false, + CtCPU: resource.MustParse("50m"), + CtMemory: resource.MustParse("128Mi"), + AgentImage: "hashicorp/vault:latest", + AgentImagePullPolicy: "IfNotPresent", + ServiceAccountTokenVolumeName: "/var/run/secrets/vault", + }, + }, + wantedPod: &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "vault-agent", + Image: "hashicorp/vault:latest", + Command: []string{"vault", "agent", "-config=/vault/agent/config.hcl", "-exit-after-auth"}, + ImagePullPolicy: "IfNotPresent", + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + }, + SecurityContext: agentInitContainerSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + MountPath: "/var/run/secrets/vault", + }, + { + Name: "vault-agent-config", + MountPath: "/vault/agent/", + }, + }, + }, + { + Name: "MyInitContainer", + Image: "myInitimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/vault", + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "consul-template", + Args: []string{"-config", "/vault/ct-config/config.hcl"}, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + Env: []corev1.EnvVar{ + { + Name: "VAULT_ADDR", + Value: "test", + }, + { + Name: "VAULT_SKIP_VERIFY", + Value: "false", + }, + }, + SecurityContext: baseSecurityContext, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "secret-init", + MountPath: "/vault/", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + { + Name: "secret-init", + MountPath: "/home/consul-template", + }, + { + Name: "ct-configmap", + ReadOnly: true, + MountPath: "/vault/ct-config/config.hcl", + SubPath: "config.hcl", + }, + }, + }, + { + Name: "MyContainer", + Image: "myimage", + Command: []string{"/bin/bash"}, + Args: nil, + VolumeMounts: []corev1.VolumeMount{ + { + MountPath: "/var/run/secrets/vault", + }, + { + Name: "ct-secrets", + MountPath: "/vault/secrets", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "secret-init", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "vault-agent-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "-vault-agent-config", + }, + }, + }, + }, + { + Name: "ct-secrets", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }, + { + Name: "ct-configmap", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "config-map-test", + }, + Items: []corev1.KeyToPath{ + { + Key: "config.hcl", + Path: "config.hcl", + }, + }, + DefaultMode: &defaultMode, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + ttp := tt + t.Run(ttp.name, func(t *testing.T) { + t.Parallel() + + mw := &MutatingWebhook{ + k8sClient: ttp.fields.k8sClient, + registry: ttp.fields.registry, + logger: slog.Default(), + } + err := mw.MutatePod(context.Background(), ttp.args.pod, ttp.args.webhookConfig, ttp.args.secretInitConfig, ttp.args.vaultConfig, false) + if (err != nil) != ttp.wantErr { + t.Errorf("MutatingWebhook.MutatePod() error = %v, wantErr %v", err, ttp.wantErr) + return + } + + if !cmp.Equal(ttp.args.pod, ttp.wantedPod) { + t.Errorf("MutatingWebhook.MutatePod() = diff %v", cmp.Diff(ttp.args.pod, ttp.wantedPod)) + } + }) + } +} diff --git a/pkg/webhook/registry.go b/pkg/webhook/registry.go new file mode 100644 index 0000000..db1a5f3 --- /dev/null +++ b/pkg/webhook/registry.go @@ -0,0 +1,218 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "net/http" + "os" + "slices" + + "emperror.dev/errors" + "github.com/google/go-containerregistry/pkg/authn/k8schain" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/patrickmn/go-cache" + slogmulti "github.com/samber/slog-multi" + "github.com/spf13/viper" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" +) + +var logger *slog.Logger + +func init() { + router := slogmulti.Router() + + levelFilter := func(levels ...slog.Level) func(ctx context.Context, r slog.Record) bool { + return func(ctx context.Context, r slog.Record) bool { + return slices.Contains(levels, r.Level) + } + } + + if viper.GetBool("enable_json_log") { + // Send logs with level higher than warning to stderr + router = router.Add(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + + // Send info and debug logs to stdout + router = router.Add( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + levelFilter(slog.LevelDebug, slog.LevelInfo), + ) + } else { + // Send logs with level higher than warning to stderr + router = router.Add(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + + // Send info and debug logs to stdout + router = router.Add( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + levelFilter(slog.LevelDebug, slog.LevelInfo), + ) + } + + // TODO: add level filter handler + logger = slog.New(router.Handler()) + + slog.SetDefault(logger) +} + +// ImageRegistry is a docker registry +type ImageRegistry interface { + GetImageConfig( + ctx context.Context, + clientset kubernetes.Interface, + namespace string, + isDisabled bool, + container *corev1.Container, + podSpec *corev1.PodSpec) (*v1.Config, error) +} + +// Registry impl +type Registry struct { + imageCache *cache.Cache +} + +// NewRegistry creates and initializes registry +func NewRegistry() ImageRegistry { + return &Registry{ + imageCache: cache.New(cache.NoExpiration, cache.NoExpiration), + } +} + +// IsAllowedToCache checks that information about Docker image can be cached +// base on image name and container PullPolicy +func IsAllowedToCache(container *corev1.Container) bool { + if container.ImagePullPolicy == corev1.PullAlways { + return false + } + + reference, err := name.ParseReference(container.Image) + if err != nil { + return false + } + + return reference.Identifier() != "latest" +} + +// GetImageConfig returns entrypoint and command of container +func (r *Registry) GetImageConfig( + ctx context.Context, + client kubernetes.Interface, + namespace string, + isDisabled bool, + container *corev1.Container, + podSpec *corev1.PodSpec) (*v1.Config, error) { + allowToCache := IsAllowedToCache(container) + if allowToCache { + if imageConfig, cacheHit := r.imageCache.Get(container.Image); cacheHit { + logger.Info(fmt.Sprintf("found image %s in cache", container.Image)) + + return imageConfig.(*v1.Config), nil + } + } + + containerInfo := containerInfo{ + Namespace: namespace, + ServiceAccountName: podSpec.ServiceAccountName, + Image: container.Image, + } + for _, imagePullSecret := range podSpec.ImagePullSecrets { + containerInfo.ImagePullSecrets = append(containerInfo.ImagePullSecrets, imagePullSecret.Name) + } + + // The pod imagePullSecrets did not contain any credentials. + // Try to find matching registry credentials in the default imagePullSecret if one was provided. + // Otherwise, cloud credential providers will be tried. + defaultImagePullSecretNamespace := viper.GetString("default_image_pull_secret_namespace") + defaultImagePullSecretServiceAccount := viper.GetString("default_image_pull_secret_service_account") + defaultImagePullSecret := viper.GetString("default_image_pull_secret") + if len(containerInfo.ImagePullSecrets) == 0 && + defaultImagePullSecretNamespace != "" && defaultImagePullSecret != "" && defaultImagePullSecretServiceAccount != "" { + containerInfo.Namespace = defaultImagePullSecretNamespace + containerInfo.ServiceAccountName = defaultImagePullSecretServiceAccount + containerInfo.ImagePullSecrets = []string{defaultImagePullSecret} + } + + imageConfig, err := getImageConfig(ctx, client, containerInfo, isDisabled) + if imageConfig != nil && allowToCache { + r.imageCache.Set(container.Image, imageConfig, cache.DefaultExpiration) + } + + return imageConfig, err +} + +// getImageConfig download image blob from registry +func getImageConfig(ctx context.Context, client kubernetes.Interface, container containerInfo, isDisabled bool) (*v1.Config, error) { + registrySkipVerify := isDisabled + + chainOpts := k8schain.Options{ + Namespace: container.Namespace, + ServiceAccountName: container.ServiceAccountName, + ImagePullSecrets: container.ImagePullSecrets, + } + + authChain, err := k8schain.New( + ctx, + client, + chainOpts, + ) + if err != nil { + return nil, errors.Wrapf(err, "failed to create k8schain authentication, opts: %+v", chainOpts) + } + + options := []remote.Option{ + remote.WithAuthFromKeychain(authChain), + } + + if registrySkipVerify { + tr := remote.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + options = append(options, remote.WithTransport(tr)) + } + + ref, err := name.ParseReference(container.Image) + if err != nil { + return nil, errors.Wrap(err, "failed to parse image reference") + } + + descriptor, err := remote.Get(ref, options...) + if err != nil { + return nil, errors.Wrap(err, "cannot fetch image descriptor") + } + + image, err := descriptor.Image() + if err != nil { + return nil, errors.Wrap(err, "cannot convert image descriptor to v1.Image") + } + + configFile, err := image.ConfigFile() + if err != nil { + return nil, errors.Wrap(err, "cannot extract config file of image") + } + + return &configFile.Config, nil +} + +// containerInfo keeps information retrieved from POD based container definition +type containerInfo struct { + Namespace string + ImagePullSecrets []string + ServiceAccountName string + Image string +} diff --git a/pkg/webhook/registry_test.go b/pkg/webhook/registry_test.go new file mode 100644 index 0000000..9b362d7 --- /dev/null +++ b/pkg/webhook/registry_test.go @@ -0,0 +1,67 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestIsAllowedToCache(t *testing.T) { + t.Parallel() + + tests := []struct { + container *corev1.Container + allowToCache bool + }{ + { + container: &corev1.Container{ + Name: "app", + Image: "foo:bar", + }, + allowToCache: true, + }, + { + container: &corev1.Container{ + Name: "app", + Image: "foo", + }, + allowToCache: false, + }, + { + container: &corev1.Container{ + Name: "app", + Image: "foo:latest", + }, + allowToCache: false, + }, + { + container: &corev1.Container{ + Name: "app", + Image: "foo:bar", + ImagePullPolicy: corev1.PullAlways, + }, + allowToCache: false, + }, + } + + for _, test := range tests { + allowToCache := IsAllowedToCache(test.container) + if test.allowToCache != allowToCache { + t.Errorf("IsAllowedToCache() != %v", test.allowToCache) + } + } +} diff --git a/pkg/webhook/secret.go b/pkg/webhook/secret.go new file mode 100644 index 0000000..a71aed1 --- /dev/null +++ b/pkg/webhook/secret.go @@ -0,0 +1,195 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "emperror.dev/errors" + "github.com/bank-vaults/internal/injector" + corev1 "k8s.io/api/core/v1" + + "github.com/bank-vaults/secrets-webhook/pkg/common" +) + +type dockerCredentials struct { + Auths map[string]dockerAuthConfig `json:"auths"` +} + +// dockerAuthConfig contains authorization information for connecting to a Registry +type dockerAuthConfig struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Auth string `json:"auth,omitempty"` + + // Email is an optional value associated with the username. + // This field is deprecated and will be removed in a later + // version of docker. + Email string `json:"email,omitempty"` + + ServerAddress string `json:"serveraddress,omitempty"` + + // IdentityToken is used to authenticate the user and get + // an access token for the registry. + IdentityToken string `json:"identitytoken,omitempty"` + + // RegistryToken is a bearer token to be sent to a registry + RegistryToken string `json:"registrytoken,omitempty"` +} + +func secretNeedsMutation(secret *corev1.Secret) (bool, error) { + for key, value := range secret.Data { + if key == corev1.DockerConfigJsonKey { + var dc dockerCredentials + err := json.Unmarshal(value, &dc) + if err != nil { + return false, errors.Wrap(err, "unmarshal dockerconfig json failed") + } + + for _, creds := range dc.Auths { + authBytes, err := base64.StdEncoding.DecodeString(creds.Auth) + if err != nil { + return false, errors.Wrap(err, "auth base64 decoding failed") + } + + auth := string(authBytes) + if common.HasVaultPrefix(auth) { + return true, nil + } + } + } else if common.HasVaultPrefix(string(value)) { + return true, nil + } else if injector.HasInlineVaultDelimiters(string(value)) { + return true, nil + } + } + return false, nil +} + +func (mw *MutatingWebhook) MutateSecret(secret *corev1.Secret, vaultConfig VaultConfig) error { + // do an early exit and don't construct the Vault client if not needed + requiredToMutate, err := secretNeedsMutation(secret) + if err != nil { + return errors.Wrap(err, "failed to check if secret needs to be mutated") + } + + if !requiredToMutate { + return nil + } + + vaultClient, err := mw.newVaultClient(vaultConfig) + if err != nil { + return errors.Wrap(err, "failed to create vault client") + } + + defer vaultClient.Close() + + config := injector.Config{ + TransitKeyID: vaultConfig.TransitKeyID, + TransitPath: vaultConfig.TransitPath, + TransitBatchSize: vaultConfig.TransitBatchSize, + } + secretInjector := injector.NewSecretInjector(config, vaultClient, nil, logger) + + if value, ok := secret.Data[corev1.DockerConfigJsonKey]; ok { + var dc dockerCredentials + err := json.Unmarshal(value, &dc) + if err != nil { + return errors.Wrap(err, "unmarshal dockerconfig json failed") + } + err = mw.mutateDockerCreds(secret, &dc, &secretInjector) + if err != nil { + return errors.Wrap(err, "mutate dockerconfig json failed") + } + } + + err = mw.mutateSecretData(secret, &secretInjector) + if err != nil { + return errors.Wrap(err, "mutate generic secret failed") + } + + return nil +} + +func (mw *MutatingWebhook) mutateDockerCreds(secret *corev1.Secret, dc *dockerCredentials, secretInjector *injector.SecretInjector) error { + assembled := dockerCredentials{Auths: map[string]dockerAuthConfig{}} + + for key, creds := range dc.Auths { + authBytes, err := base64.StdEncoding.DecodeString(creds.Auth) + if err != nil { + return errors.Wrap(err, "auth base64 decoding failed") + } + + auth := string(authBytes) + if common.HasVaultPrefix(auth) { + split := strings.Split(auth, ":") + if len(split) != 4 { + return errors.New("splitting auth credentials failed") + } + username := fmt.Sprintf("%s:%s", split[0], split[1]) + password := fmt.Sprintf("%s:%s", split[2], split[3]) + + credentialData := map[string]string{ + "username": username, + "password": password, + } + + dcCreds, err := secretInjector.GetDataFromVault(credentialData) + if err != nil { + return err + } + auth = fmt.Sprintf("%s:%s", dcCreds["username"], dcCreds["password"]) + dockerAuth := dockerAuthConfig{ + Auth: base64.StdEncoding.EncodeToString([]byte(auth)), + } + if creds.Username != "" && creds.Password != "" { + dockerAuth.Username = dcCreds["username"] + dockerAuth.Password = dcCreds["password"] + } + assembled.Auths[key] = dockerAuth + } + } + + marshaled, err := json.Marshal(assembled) + if err != nil { + return errors.Wrap(err, "marshaling dockerconfig failed") + } + + secret.Data[corev1.DockerConfigJsonKey] = marshaled + + return nil +} + +func (mw *MutatingWebhook) mutateSecretData(secret *corev1.Secret, secretInjector *injector.SecretInjector) error { + convertedData := make(map[string]string, len(secret.Data)) + + for k := range secret.Data { + convertedData[k] = string(secret.Data[k]) + } + + convertedData, err := secretInjector.GetDataFromVault(convertedData) + if err != nil { + return err + } + + for k := range secret.Data { + secret.Data[k] = []byte(convertedData[k]) + } + + return nil +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go new file mode 100644 index 0000000..c7c1575 --- /dev/null +++ b/pkg/webhook/webhook.go @@ -0,0 +1,330 @@ +// Copyright © 2021 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "crypto/x509" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "text/template" + + "emperror.dev/errors" + "github.com/bank-vaults/internal/injector" + "github.com/bank-vaults/vault-sdk/vault" + vaultapi "github.com/hashicorp/vault/api" + "github.com/slok/kubewebhook/v2/pkg/log" + "github.com/slok/kubewebhook/v2/pkg/model" + "github.com/slok/kubewebhook/v2/pkg/webhook/mutating" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/kubernetes" + + "github.com/bank-vaults/secrets-webhook/pkg/common" +) + +type MutatingWebhook struct { + k8sClient kubernetes.Interface + namespace string + registry ImageRegistry + logger *slog.Logger +} + +func (mw *MutatingWebhook) VaultSecretsMutator(ctx context.Context, ar *model.AdmissionReview, obj metav1.Object) (*mutating.MutatorResult, error) { + webhookConfig := parseConfig(obj) + secretInitConfig := parseSecretInitConfig(obj) + vaultConfig := parseVaultConfig(obj, ar) + + if webhookConfig.Mutate { + return &mutating.MutatorResult{}, nil + } + + // parse resulting vaultConfig.Role as potential template with fields of vaultConfig + tmpl, err := template.New("vaultRole").Option("missingkey=error").Parse(vaultConfig.Role) + if err != nil { + return &mutating.MutatorResult{}, errors.Wrap(err, "error parsing vault_role") + } + var vRoleBuf strings.Builder + if err = tmpl.Execute(&vRoleBuf, map[string]string{ + "authmethod": vaultConfig.AuthMethod, + "name": obj.GetName(), + "namespace": vaultConfig.ObjectNamespace, + "path": vaultConfig.Path, + "serviceaccount": vaultConfig.VaultServiceAccount, + }); err != nil { + return &mutating.MutatorResult{}, errors.Wrap(err, "error templating vault_role") + } + vaultConfig.Role = vRoleBuf.String() + mw.logger.Debug(fmt.Sprintf("vaultConfig.Role = '%s'", vaultConfig.Role)) + + switch v := obj.(type) { + case *corev1.Pod: + return &mutating.MutatorResult{MutatedObject: v}, mw.MutatePod(ctx, v, webhookConfig, secretInitConfig, vaultConfig, ar.DryRun) + + case *corev1.Secret: + return &mutating.MutatorResult{MutatedObject: v}, mw.MutateSecret(v, vaultConfig) + + case *corev1.ConfigMap: + return &mutating.MutatorResult{MutatedObject: v}, mw.MutateConfigMap(v, vaultConfig) + + case *unstructured.Unstructured: + return &mutating.MutatorResult{MutatedObject: v}, mw.MutateObject(v, vaultConfig) + + default: + return &mutating.MutatorResult{}, nil + } +} + +func (mw *MutatingWebhook) getDataFromConfigmap(cmName string, ns string) (map[string]string, error) { + configMap, err := mw.k8sClient.CoreV1().ConfigMaps(ns).Get(context.Background(), cmName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return configMap.Data, nil +} + +func (mw *MutatingWebhook) getDataFromSecret(secretName string, ns string) (map[string][]byte, error) { + secret, err := mw.k8sClient.CoreV1().Secrets(ns).Get(context.Background(), secretName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return secret.Data, nil +} + +func (mw *MutatingWebhook) lookForEnvFrom(envFrom []corev1.EnvFromSource, ns string) ([]corev1.EnvVar, error) { + var envVars []corev1.EnvVar + + for _, ef := range envFrom { + if ef.ConfigMapRef != nil { + data, err := mw.getDataFromConfigmap(ef.ConfigMapRef.Name, ns) + if err != nil { + if apierrors.IsNotFound(err) || (ef.ConfigMapRef.Optional != nil && *ef.ConfigMapRef.Optional) { + continue + } + + return envVars, err + } + for key, value := range data { + if common.HasVaultPrefix(value) || injector.HasInlineVaultDelimiters(value) { + envFromCM := corev1.EnvVar{ + Name: key, + Value: value, + } + envVars = append(envVars, envFromCM) + } + } + } + if ef.SecretRef != nil { + data, err := mw.getDataFromSecret(ef.SecretRef.Name, ns) + if err != nil { + if apierrors.IsNotFound(err) || (ef.SecretRef.Optional != nil && *ef.SecretRef.Optional) { + continue + } + + return envVars, err + } + for name, v := range data { + value := string(v) + if common.HasVaultPrefix(value) || injector.HasInlineVaultDelimiters(value) { + envFromSec := corev1.EnvVar{ + Name: name, + Value: value, + } + envVars = append(envVars, envFromSec) + } + } + } + } + return envVars, nil +} + +func (mw *MutatingWebhook) lookForValueFrom(env corev1.EnvVar, ns string) (*corev1.EnvVar, error) { + if env.ValueFrom.ConfigMapKeyRef != nil { + data, err := mw.getDataFromConfigmap(env.ValueFrom.ConfigMapKeyRef.Name, ns) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + value := data[env.ValueFrom.ConfigMapKeyRef.Key] + if common.HasVaultPrefix(value) || injector.HasInlineVaultDelimiters(value) { + fromCM := corev1.EnvVar{ + Name: env.Name, + Value: value, + } + return &fromCM, nil + } + } + if env.ValueFrom.SecretKeyRef != nil { + data, err := mw.getDataFromSecret(env.ValueFrom.SecretKeyRef.Name, ns) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + value := string(data[env.ValueFrom.SecretKeyRef.Key]) + if common.HasVaultPrefix(value) || injector.HasInlineVaultDelimiters(value) { + fromSecret := corev1.EnvVar{ + Name: env.Name, + Value: value, + } + return &fromSecret, nil + } + } + return nil, nil +} + +func (mw *MutatingWebhook) newVaultClient(vaultConfig VaultConfig) (*vault.Client, error) { + clientConfig := vaultapi.DefaultConfig() + if clientConfig.Error != nil { + return nil, clientConfig.Error + } + + clientConfig.Address = vaultConfig.Addr + + tlsConfig := vaultapi.TLSConfig{Insecure: vaultConfig.SkipVerify} + err := clientConfig.ConfigureTLS(&tlsConfig) + if err != nil { + return nil, err + } + + if vaultConfig.TLSSecret != "" { + tlsSecret, err := mw.k8sClient.CoreV1().Secrets(mw.namespace).Get( + context.Background(), + vaultConfig.TLSSecret, + metav1.GetOptions{}, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to read Vault TLS Secret") + } + + clientTLSConfig := clientConfig.HttpClient.Transport.(*http.Transport).TLSClientConfig + + pool := x509.NewCertPool() + + ok := pool.AppendCertsFromPEM(tlsSecret.Data["ca.crt"]) + if !ok { + return nil, errors.Errorf("error loading Vault CA PEM from TLS Secret: %s", tlsSecret.Name) + } + + clientTLSConfig.RootCAs = pool + } + + if vaultConfig.VaultServiceAccount != "" { + sa, err := mw.k8sClient.CoreV1().ServiceAccounts(vaultConfig.ObjectNamespace).Get(context.Background(), vaultConfig.VaultServiceAccount, metav1.GetOptions{}) + if err != nil { + return nil, errors.Wrap(err, "Failed to retrieve specified service account on namespace "+vaultConfig.ObjectNamespace) + } + + saToken := "" + if len(sa.Secrets) > 0 { + secret, err := mw.k8sClient.CoreV1().Secrets(vaultConfig.ObjectNamespace).Get(context.Background(), sa.Secrets[0].Name, metav1.GetOptions{}) + if err != nil { + return nil, errors.Wrap(err, "Failed to retrieve secret for service account "+sa.Secrets[0].Name+" in namespace "+vaultConfig.ObjectNamespace) + } + saToken = string(secret.Data["token"]) + } + + if saToken == "" { + tokenTTL := int64(600) // min allowed duration is 10 mins + tokenRequest := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"https://kubernetes.default.svc"}, + ExpirationSeconds: &tokenTTL, + }, + } + + token, err := mw.k8sClient.CoreV1().ServiceAccounts(vaultConfig.ObjectNamespace).CreateToken( + context.Background(), + vaultConfig.VaultServiceAccount, + tokenRequest, + metav1.CreateOptions{}, + ) + if err != nil { + return nil, errors.Wrap(err, "Failed to create a token for the specified service account "+vaultConfig.VaultServiceAccount+" on namespace "+vaultConfig.ObjectNamespace) + } + saToken = token.Status.Token + } + + return vault.NewClientFromConfig( + clientConfig, + vault.ClientRole(vaultConfig.Role), + vault.ClientAuthPath(vaultConfig.Path), + vault.NamespacedSecretAuthMethod, + vault.ClientLogger(&clientLogger{logger: mw.logger}), + vault.ExistingSecret(saToken), + vault.VaultNamespace(vaultConfig.VaultNamespace), + ) + } + + return vault.NewClientFromConfig( + clientConfig, + vault.ClientRole(vaultConfig.Role), + vault.ClientAuthPath(vaultConfig.Path), + vault.ClientAuthMethod(vaultConfig.AuthMethod), + vault.ClientLogger(&clientLogger{logger: mw.logger}), + vault.VaultNamespace(vaultConfig.VaultNamespace), + ) +} + +func (mw *MutatingWebhook) ServeMetrics(addr string, handler http.Handler) { + mw.logger.Info(fmt.Sprintf("Telemetry on http://%s", addr)) + + mux := http.NewServeMux() + mux.Handle("/metrics", handler) + err := http.ListenAndServe(addr, mux) + if err != nil { + mw.logger.Error(fmt.Errorf("error serving telemetry: %w", err).Error()) + os.Exit(1) + } +} + +func NewMutatingWebhook(logger *slog.Logger, k8sClient kubernetes.Interface) (*MutatingWebhook, error) { + namespace := os.Getenv("KUBERNETES_NAMESPACE") // only for kurun + if namespace == "" { + namespaceBytes, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err != nil { + return nil, errors.Wrap(err, "error reading k8s namespace") + } + namespace = string(namespaceBytes) + } + + return &MutatingWebhook{ + k8sClient: k8sClient, + namespace: namespace, + registry: NewRegistry(), + logger: logger, + }, nil +} + +func ErrorLoggerMutator(mutator mutating.MutatorFunc, logger log.Logger) mutating.MutatorFunc { + return func(ctx context.Context, ar *model.AdmissionReview, obj metav1.Object) (result *mutating.MutatorResult, err error) { + r, err := mutator(ctx, ar, obj) + if err != nil { + logger.WithCtxValues(ctx).WithValues(log.Kv{ + "error": err, + }).Errorf("Admission review request failed") + } + return r, err + } +} diff --git a/pkg/webhook/webhook_logger.go b/pkg/webhook/webhook_logger.go new file mode 100644 index 0000000..3e91a0f --- /dev/null +++ b/pkg/webhook/webhook_logger.go @@ -0,0 +1,71 @@ +// Copyright © 2023 Bank-Vaults Maintainers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "fmt" + "log/slog" + + "github.com/slok/kubewebhook/v2/pkg/log" +) + +var _ log.Logger = &whLogger{} + +type whLogger struct { + *slog.Logger +} + +// NewWhLogger returns a new log.Logger for a slog implementation. +func NewWhLogger(l *slog.Logger) log.Logger { + return whLogger{l} +} + +func (l whLogger) Infof(format string, args ...interface{}) { + l.Info(fmt.Sprintf(format, args...)) +} + +func (l whLogger) Warningf(format string, args ...interface{}) { + l.Warn(fmt.Sprintf(format, args...)) +} + +func (l whLogger) Errorf(format string, args ...interface{}) { + l.Error(fmt.Sprintf(format, args...)) +} + +func (l whLogger) Debugf(format string, args ...interface{}) { + l.Debug(fmt.Sprintf(format, args...)) +} + +func (l whLogger) WithValues(kv log.Kv) log.Logger { + attributes := make([]any, 0, len(kv)) + for k, v := range kv { + attributes = append(attributes, slog.Any(k, v)) + } + return NewWhLogger(l.With(attributes...)) +} + +func (l whLogger) WithCtxValues(ctx context.Context) log.Logger { + ctxValues := log.ValuesFromCtx(ctx) + attributes := make([]any, 0, len(ctxValues)) + for k, v := range ctxValues { + attributes = append(attributes, slog.Any(k, v)) + } + return NewWhLogger(l.With(attributes...)) +} + +func (l whLogger) SetValuesOnCtx(parent context.Context, values log.Kv) context.Context { + return log.CtxWithValues(parent, values) +} diff --git a/project.garden.yaml b/project.garden.yaml new file mode 100644 index 0000000..49698b0 --- /dev/null +++ b/project.garden.yaml @@ -0,0 +1,23 @@ +# Documentation about Garden projects can be found at https://docs.garden.io/using-garden/projects +# Reference for Garden projects can be found at https://docs.garden.io/reference/project-config +apiVersion: garden.io/v1 +kind: Project +name: secrets-webhook +dotIgnoreFile: .gitignore +defaultEnvironment: local + +environments: + - name: local + defaultNamespace: default + +providers: + - name: local-kubernetes + environments: [local] + setupIngressController: null + +scan: + exclude: + - .direnv/**/* + - .devenv/**/* + - build/**/* + - e2e/**/* diff --git a/tlscertificate.go b/tlscertificate.go new file mode 100644 index 0000000..922c877 --- /dev/null +++ b/tlscertificate.go @@ -0,0 +1,100 @@ +// Copyright © 2023 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "crypto/tls" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + + "github.com/fsnotify/fsnotify" +) + +type CertificateReloader struct { + certMu sync.RWMutex + cert *tls.Certificate + certPath string + keyPath string +} + +func NewCertificateReloader(certPath string, keyPath string) (*CertificateReloader, error) { + result := &CertificateReloader{ + certPath: certPath, + keyPath: keyPath, + } + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, err + } + result.cert = &cert + + go result.watchCertificate() + + return result, nil +} + +func (kpr *CertificateReloader) watchCertificate() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + defer watcher.Close() + + certDir, _ := filepath.Split(kpr.certPath) + slog.Info(fmt.Sprintf("watching directory for changes: %s", certDir)) + err = watcher.Add(certDir) + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + + for { + select { + case event := <-watcher.Events: + if (event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write) && filepath.Base(event.Name) == "..data" { + if err := kpr.Reload(); err != nil { + slog.Error(fmt.Errorf("keeping old certificate because the new one could not be loaded: %w", err).Error()) + } else { + slog.Info(fmt.Sprintf("Certificate has change, reloading: %s", kpr.certPath)) + } + } + case err := <-watcher.Errors: + slog.Error(fmt.Errorf("watcher event error: %w", err).Error()) + } + } +} + +func (kpr *CertificateReloader) Reload() error { + newCert, err := tls.LoadX509KeyPair(kpr.certPath, kpr.keyPath) + if err != nil { + return err + } + kpr.certMu.Lock() + defer kpr.certMu.Unlock() + kpr.cert = &newCert + return nil +} + +func (kpr *CertificateReloader) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + kpr.certMu.RLock() + defer kpr.certMu.RUnlock() + return kpr.cert, nil + } +}