├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── actionlint.yaml │ ├── backport.yml │ ├── bulk-dep-upgrades.yaml │ ├── jira.yaml │ └── tests.yaml ├── .gitignore ├── .go-version ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── Makefile ├── README.md ├── bootstrap ├── configure.sh └── terraform │ └── service-account.tf ├── cmd └── vault-plugin-secrets-gcp │ └── main.go ├── go.mod ├── go.sum ├── plugin ├── backend.go ├── backend_test.go ├── cache │ └── cache.go ├── field_data_utils.go ├── gcp_account_resources.go ├── gcp_account_resources_test.go ├── iamutil │ ├── api_handle.go │ ├── api_handle_test.go │ ├── dataset_resource.go │ ├── dataset_resource_test.go │ ├── iam_policy.go │ ├── iam_policy_test.go │ ├── iam_resource.go │ ├── iam_resource_test.go │ ├── internal │ │ ├── generate_resources.go │ │ ├── resource_config_template │ │ └── resource_overrides.go │ ├── resource.go │ ├── resource_parser.go │ ├── resource_parser_test.go │ └── resources_generated.go ├── impersonated_account.go ├── path_config.go ├── path_config_ent_test.go ├── path_config_rotate_root.go ├── path_config_rotate_root_test.go ├── path_config_test.go ├── path_impersonated_account.go ├── path_impersonated_account_secrets.go ├── path_impersonated_account_secrets_test.go ├── path_impersonated_account_test.go ├── path_role_set.go ├── path_role_set_secrets.go ├── path_role_set_secrets_test.go ├── path_role_set_test.go ├── path_static_account.go ├── path_static_account_rotate_key.go ├── path_static_account_rotate_key_test.go ├── path_static_account_secrets.go ├── path_static_account_secrets_test.go ├── path_static_account_test.go ├── role_set.go ├── rollback.go ├── secrets_access_token.go ├── secrets_service_account_key.go ├── secrets_test.go ├── static_account.go └── util │ ├── bindings_template │ ├── parse_bindings.go │ ├── parse_bindings_test.go │ ├── string_set.go │ └── testing.go ├── scripts ├── build.sh ├── dev.sh ├── gofmtcheck.sh ├── gohelpers │ └── create_custom_role.go └── update_deps.sh └── tests └── acceptance ├── README.md ├── configs └── mybindings.hcl └── gcp-secrets.bats /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | A high level description of the contribution, including: 3 | Who the change affects or is for (stakeholders)? 4 | What is the change? 5 | Why is the change needed? 6 | How does this change affect the user experience (if at all)? 7 | 8 | # Design of Change 9 | How was this change implemented? 10 | 11 | # Related Issues/Pull Requests 12 | [ ] [Issue #1234](https://github.com/hashicorp/vault/issues/1234) 13 | [ ] [PR #1234](https://github.com/hashicorp/vault/pr/1234) 14 | 15 | # Contributor Checklist 16 | [ ] Add relevant docs to upstream Vault repository, or sufficient reasoning why docs won’t be added yet 17 | [My Docs PR Link](link) 18 | [Example](https://github.com/hashicorp/vault/commit/2715f5cec982aabc7b7a6ae878c547f6f475bba6) 19 | [ ] Add output for any tests not ran in CI to the PR description (eg, acceptance tests) 20 | [ ] Backwards compatible 21 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint GitHub Actions Workflows 2 | on: 3 | push: 4 | paths: 5 | - '.github/workflows/**' 6 | jobs: 7 | actionlint: 8 | # using `main` as the ref will keep your workflow up-to-date 9 | uses: hashicorp/vault-workflows-common/.github/workflows/actionlint.yaml@main 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/backport.yml: -------------------------------------------------------------------------------- 1 | name: Backport assistant runner 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - closed 7 | - labeled 8 | 9 | jobs: 10 | backport: 11 | runs-on: ubuntu-latest 12 | container: hashicorpdev/backport-assistant:0.4.3 13 | steps: 14 | - name: Configure git with a token that has sufficient privileges 15 | run: | 16 | git config --global url."https://${{ secrets.VAULT_ECO_GITHUB_TOKEN }}@github.com".insteadOf https://github.com 17 | - name: Backport changes to targeted release branch 18 | run: backport-assistant backport -merge-method=squash -gh-automerge 19 | env: 20 | BACKPORT_LABEL_REGEXP: "backport/vault-(?P\\d+\\.\\d+\\.\\w+)" 21 | BACKPORT_TARGET_TEMPLATE: "release/vault-{{.target}}" 22 | BACKPORT_MERGE_COMMIT: true 23 | GITHUB_TOKEN: ${{ secrets.VAULT_ECO_GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/bulk-dep-upgrades.yaml: -------------------------------------------------------------------------------- 1 | name: Upgrade dependencies 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | # Runs 12:00AM on the first of every month 6 | - cron: '0 0 1 * *' 7 | jobs: 8 | upgrade: 9 | # using `main` as the ref will keep your workflow up-to-date 10 | uses: hashicorp/vault-workflows-common/.github/workflows/bulk-dependency-updates.yaml@main 11 | secrets: 12 | VAULT_ECO_GITHUB_TOKEN: ${{ secrets.VAULT_ECO_GITHUB_TOKEN }} 13 | with: 14 | reviewer-team: hashicorp/vault-ecosystem-applications 15 | repository: ${{ github.repository }} 16 | run-id: ${{ github.run_id }} 17 | -------------------------------------------------------------------------------- /.github/workflows/jira.yaml: -------------------------------------------------------------------------------- 1 | name: Jira Sync 2 | on: 3 | issues: 4 | types: [opened, closed, deleted, reopened] 5 | pull_request_target: 6 | types: [opened, closed, reopened] 7 | issue_comment: # Also triggers when commenting on a PR from the conversation view 8 | types: [created] 9 | jobs: 10 | sync: 11 | uses: hashicorp/vault-workflows-common/.github/workflows/jira.yaml@main 12 | # assuming you use Vault to get secrets 13 | # if you use GitHub secrets, use secrets.XYZ instead of steps.secrets.outputs.XYZ 14 | secrets: 15 | JIRA_SYNC_BASE_URL: ${{ secrets.JIRA_SYNC_BASE_URL }} 16 | JIRA_SYNC_USER_EMAIL: ${{ secrets.JIRA_SYNC_USER_EMAIL }} 17 | JIRA_SYNC_API_TOKEN: ${{ secrets.JIRA_SYNC_API_TOKEN }} 18 | with: 19 | teams-array: '["ecosystem", "applications"]' 20 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | push: 4 | jobs: 5 | run-tests: 6 | # using `main` as the ref will keep your workflow up-to-date 7 | uses: hashicorp/vault-workflows-common/.github/workflows/tests.yaml@main 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | .cover 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | *.prof 26 | 27 | # Other dirs 28 | /bin/ 29 | /pkg/ 30 | 31 | # Vault-specific 32 | example.hcl 33 | example.vault.d 34 | 35 | # Ruby 36 | website/vendor 37 | website/.bundle 38 | website/build 39 | 40 | # Vagrant 41 | .vagrant/ 42 | Vagrantfile 43 | 44 | 45 | .DS_Store 46 | .idea 47 | .vscode 48 | 49 | dist/* 50 | 51 | tags 52 | 53 | # Editor backups 54 | *~ 55 | *.sw[a-z] 56 | 57 | # IntelliJ IDEA project files 58 | .idea 59 | *.ipr 60 | *.iml 61 | 62 | # compiled output 63 | ui/dist 64 | ui/tmp 65 | 66 | # dependencies 67 | ui/node_modules 68 | ui/bower_components 69 | 70 | # misc 71 | ui/.DS_Store 72 | ui/.sass-cache 73 | ui/connect.lock 74 | ui/coverage/* 75 | ui/libpeerconnection.log 76 | ui/npm-debug.log 77 | ui/testem.log 78 | 79 | # IAM 80 | vault-tester.json 81 | local_environment_setup.sh 82 | 83 | # Local .terraform directories 84 | **/.terraform/* 85 | .terraform.lock.hcl 86 | 87 | # .tfstate files 88 | *.tfstate 89 | *.tfstate.* 90 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.23.6 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ## v0.21.3 4 | 5 | BUG FIXES: 6 | * Fix a panic when a performance standby node attempts to write/update config: [GH-249](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/249) 7 | 8 | ## v0.21.2 9 | IMPROVEMENTS: 10 | * Update dependencies: 11 | * `golang.org/x/net` v0.35.0 -> v0.36.0: [GH-245](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/245) 12 | * Update Go version to 1.23.6 13 | 14 | BUG FIXES: 15 | * Ensure service accounts are fully propagated in GCP before creating IAM bindings: [GH-246](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/246) 16 | 17 | ## v0.21.1 18 | IMPROVEMENTS: 19 | * Update dependencies: 20 | * `golang.org/x/crypto` v0.33.0 -> v0.35.0 21 | * `github.com/go-jose/go-jose/v4` v4.0.4 -> v4.0.5 22 | * `github.com/hashicorp/vault/sdk` v0.15.0 -> v0.15.2 23 | * `golang.org/x/oauth2` v0.26.0 -> v0.27.0 24 | 25 | ## v0.21.0 26 | IMPROVEMENTS: 27 | * Add support for Vault Enterprise automated root rotation 28 | * Update dependencies [GH-228](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/228) 29 | * Update Go version to 1.23.3 30 | 31 | ## v0.20.0 32 | IMPROVEMENTS: 33 | * Bump `github.com/docker/docker` from 24.0.9+incompatible to 25.0.6+incompatible: [GH-221](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/221) 34 | * Bump `github.com/hashicorp/go-retryablehttp` from 0.7.1 to 0.7.7: [GH-217](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/217) 35 | * Updated dependencies [[GH-223](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/223)]: 36 | * `google.golang.org/api` v0.192.0 -> v0.195.0 37 | 38 | BUG FIXES: 39 | * Fix resource generation target: [GH-216](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/216) 40 | * Add missing generated resources: [GH-218](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/218) 41 | 42 | ## v0.19.0 43 | 44 | IMPROVEMENTS: 45 | * Bump github.com/hashicorp/go-plugin from v1.5.2 to v1.6.0 to enable running the plugin in containers: [GH-207](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/207) 46 | * Support Workload Identity Federation: [GH-210](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/210) 47 | * Bump `golang.org/x/net` from 0.20.0 to 0.23.0: [GH-209](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/209) 48 | * Bump `github.com/docker/docker` from v24.0.7+incompatible to v24.0.9+incompatible: [GH-212](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/212) 49 | * Updated dependencies [[GH-206](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/206)]: 50 | * `github.com/hashicorp/go-hclog` v1.6.2 -> v1.6.3 51 | * `github.com/hashicorp/vault/api` v1.11.0 -> v1.13.0 52 | * `github.com/hashicorp/vault/sdk` v0.10.2 -> v0.12.0 53 | * `golang.org/x/oauth2` v0.16.0 -> v0.19.0 54 | * `google.golang.org/api` v0.161.0 -> v0.177.0 55 | * `github.com/go-jose/go-jose/v3` -> `github.com/go-jose/go-jose/v4` 56 | 57 | ## v0.18.0 58 | 59 | IMPROVEMENTS: 60 | * Updated dependencies [[GH-198](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/198)]: 61 | * `github.com/hashicorp/go-hclog` v1.5.0 -> v1.6.2 62 | * `github.com/hashicorp/vault/api` v1.9.2 -> v1.11.0 63 | * `github.com/hashicorp/vault/sdk` v0.9.2 -> v0.10.2 64 | * `golang.org/x/oauth2` v0.11.0 -> v0.16.0 65 | * `google.golang.org/api` v0.138.0 -> v0.161.0 66 | * Bump golang.org/x/crypto from 0.12.0 to 0.17.0: [GH-197](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/197) 67 | * Bump github.com/go-jose/go-jose/v3 from 3.0.0 to 3.0.1: [GH-196](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/196) 68 | * Bump google.golang.org/grpc from 1.57.0 to 1.57.1: [GH-195](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/195) 69 | * Bump golang.org/x/net from 0.14.0 to 0.17.0: [GH-194](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/194) 70 | * Bump github.com/docker/docker from 24.0.5+incompatible to 24.0.7+incompatible: [GH-199](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/199) 71 | 72 | ## v0.17.0 73 | 74 | CHANGES: 75 | * Shuffle around operation IDs to present the best generated client library interface [[GH-190](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/190)] 76 | 77 | IMPROVEMENTS: 78 | * Add missing `Query: true` metadata to API definitions [[GH-189](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/189)] 79 | * Updated dependencies [[GH-191](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/191)]: 80 | * `github.com/hashicorp/hcl` v1.0.0 -> v1.0.1-vault-5 81 | * `github.com/hashicorp/vault/api` v1.9.1 -> v1.9.2 82 | * `github.com/hashicorp/vault/sdk` v0.9.0 -> v0.9.2 83 | * `golang.org/x/oauth2` v0.8.0 -> v0.11.0 84 | * `google.golang.org/api` v0.124.0 -> v0.138.0 85 | 86 | ## v0.16.0 87 | 88 | IMPROVEMENTS: 89 | * Enable multiplexing [[GH-172](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/172)] 90 | * Updated dependencies: 91 | * `github.com/hashicorp/go-hclog` v1.4.0 -> v1.5.0 92 | * `github.com/hashicorp/vault/api` v1.8.3 -> v1.9.1 93 | * `github.com/hashicorp/vault/sdk` v0.7.0 -> v0.9.0 94 | * `golang.org/x/oauth2` v0.4.0 -> v0.8.0 95 | * `google.golang.org/api` v0.109.0 -> v0.124.0 96 | 97 | ## v0.15.0 98 | 99 | IMPROVEMENTS: 100 | 101 | * Added support for impersonated accounts [[GH-129](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/129)} 102 | 103 | BUG FIXES: 104 | 105 | * Fix issue where IAM bindings were not preserved during policy update [[GH-114](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/114)] 106 | * Fix issue where duplicate service account keys would be created for rotate root 107 | on standby or [[GH-153](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/153)] 108 | * Changes user-agent header value to use correct Vault version information and include 109 | the plugin type and name in the comment section. [[GH-164](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/164)] 110 | 111 | ## v0.14.0 112 | 113 | IMPROVEMENTS: 114 | 115 | * Updates dependencies: `google.golang.org/api@v0.83.0`, `github.com/hashicorp/go-gcp-common@v0.8.0` [[GH-142](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/142)] 116 | 117 | ## v0.13.1 118 | 119 | BUG FIXES: 120 | 121 | * Fixes duplicate static account key creation from performance secondary clusters [[GH-144](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/144)] 122 | 123 | ## v0.12.1 124 | 125 | BUG FIXES: 126 | 127 | * Fixes duplicate static account key creation from performance secondary clusters [[GH-144](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/144)] 128 | 129 | ## v0.11.1 130 | 131 | BUG FIXES: 132 | 133 | * Fixes role bindings for BigQuery dataset resources [[GH-130](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/130)] 134 | 135 | ## v0.10.3 136 | 137 | BUG FIXES: 138 | 139 | * Fixes role bindings for BigQuery dataset resources [[GH-130](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/130)] 140 | 141 | ## v0.9.1 142 | 143 | BUG FIXES: 144 | 145 | * Fixes role bindings for BigQuery dataset resources [[GH-130](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/130)] 146 | 147 | ## 0.8.1 148 | 149 | IMPROVEMENTS: 150 | 151 | * Truncate ServiceAccount display names longer than 100 characters [[GH-87](https://github.com/hashicorp/vault-plugin-secrets-gcp/pull/87)] 152 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @hashicorp/vault-ecosystem 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TOOL?=vault-gcp-secrets-plugin 2 | TEST?=$$(go list ./... | grep -v /vendor/) 3 | VETARGS?=-asmdecl -atomic -bool -buildtags -copylocks -methods -nilfunc -printf -rangeloops -shift -structtags -unsafeptr 4 | EXTERNAL_TOOLS= 5 | BUILD_TAGS?=${TOOL} 6 | GOFMT_FILES?=$$(find . -name '*.go' | grep -v vendor) 7 | 8 | PLUGIN_NAME?=$(shell command ls bin/) 9 | PLUGIN_DIR?=$$GOPATH/vault-plugins 10 | PLUGIN_PATH?=local-gcp 11 | 12 | # bin generates the releasable binaries for this plugin 13 | .PHONY: bin 14 | bin: fmtcheck generate 15 | @CGO_ENABLED=0 BUILD_TAGS='$(BUILD_TAGS)' sh -c "'$(CURDIR)/scripts/build.sh'" 16 | 17 | .PHONY: default 18 | default: dev 19 | 20 | # dev creates binaries for testing Vault locally. These are put 21 | # into ./bin/ as well as $GOPATH/bin, except for quickdev which 22 | # is only put into /bin/ 23 | .PHONY: quickdev 24 | quickdev: generate 25 | @CGO_ENABLED=0 go build -tags='$(BUILD_TAGS)' -o bin/vault-plugin-secrets-gcp cmd/vault-plugin-secrets-gcp/main.go 26 | .PHONY: dev 27 | dev: fmtcheck generate 28 | @CGO_ENABLED=0 BUILD_TAGS='$(BUILD_TAGS)' VAULT_DEV_BUILD=1 sh -c "'$(CURDIR)/scripts/build.sh'" 29 | .PHONY: dev-dynamic 30 | dev-dynamic: generate 31 | @CGO_ENABLED=1 BUILD_TAGS='$(BUILD_TAGS)' VAULT_DEV_BUILD=1 sh -c "'$(CURDIR)/scripts/build.sh'" 32 | 33 | .PHONY: testcompile 34 | testcompile: fmtcheck generate 35 | @for pkg in $(TEST) ; do \ 36 | go test -v -c -tags='$(BUILD_TAGS)' $$pkg -parallel=4 ; \ 37 | done 38 | 39 | .PHONY: test 40 | test: 41 | @go test -short ./... $(TESTARGS) 42 | 43 | .PHONY: testacc 44 | testacc: 45 | @go test ./... $(TESTARGS) 46 | 47 | # generate runs `go generate` to build the dynamically generated 48 | # source files. 49 | .PHONY: generate 50 | generate: 51 | @go generate $(shell go list ./plugin/... | grep -v /vendor/) 52 | 53 | # bootstrap the build by downloading additional tools 54 | .PHONY: bootstrap 55 | bootstrap: 56 | @for tool in $(EXTERNAL_TOOLS) ; do \ 57 | echo "Installing/Updating $$tool" ; \ 58 | go get -u $$tool; \ 59 | done 60 | 61 | .PHONY: fmtcheck 62 | fmtcheck: 63 | @sh -c "'$(CURDIR)/scripts/gofmtcheck.sh'" 64 | 65 | .PHONY: fmt 66 | fmt: 67 | gofmt -w $(GOFMT_FILES) && cd bootstrap/terraform && terraform fmt 68 | 69 | .PHONY: update-resources 70 | update-resources: 71 | go run ./plugin/iamutil/internal 72 | 73 | .PHONY: setup-env 74 | setup-env: 75 | cd bootstrap/terraform && terraform init && terraform apply -auto-approve 76 | 77 | .PHONY: teardown-env 78 | teardown-env: 79 | cd bootstrap/terraform && terraform init && terraform destroy -auto-approve 80 | 81 | .PHONY: configure 82 | configure: dev 83 | @./bootstrap/configure.sh \ 84 | $(PLUGIN_DIR) \ 85 | $(PLUGIN_NAME) \ 86 | $(PLUGIN_PATH) \ 87 | $(GOOGLE_TEST_CREDENTIALS) 88 | -------------------------------------------------------------------------------- /bootstrap/configure.sh: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | PLUGIN_DIR=$1 5 | PLUGIN_NAME=$2 6 | PLUGIN_PATH=$3 7 | GOOGLE_TEST_CREDENTIALS=$4 8 | 9 | # Try to clean-up previous runs 10 | vault plugin deregister "$PLUGIN_NAME" 11 | vault secrets disable "$PLUGIN_PATH" 12 | killall "$PLUGIN_NAME" 13 | 14 | # Give a bit of time for the binary file to be released so we can copy over it 15 | sleep 3 16 | 17 | # Copy the binary so text file is not busy when rebuilding & the plugin is registered 18 | cp ./bin/"$PLUGIN_NAME" "$PLUGIN_DIR"/"$PLUGIN_NAME" 19 | 20 | # Sets up the binary with local changes 21 | vault plugin register \ 22 | -sha256="$(shasum -a 256 "$PLUGIN_DIR"/"$PLUGIN_NAME" | awk '{print $1}')" \ 23 | secret "$PLUGIN_NAME" 24 | vault secrets enable --plugin-name="$PLUGIN_NAME" --path="$PLUGIN_PATH" plugin 25 | vault write "$PLUGIN_PATH"/config credentials=@"$GOOGLE_TEST_CREDENTIALS" 26 | -------------------------------------------------------------------------------- /bootstrap/terraform/service-account.tf: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | variable "GOOGLE_CLOUD_PROJECT_ID" {} 5 | 6 | provider "google" { 7 | // Credentials and configuration derived from the environment 8 | // Uncomment if you wish to configure the provider explicitly 9 | // credentials = "${file("account.json")}" 10 | // region = "us-central1" 11 | // zone = "us-central1-c 12 | 13 | project = var.GOOGLE_CLOUD_PROJECT_ID 14 | } 15 | 16 | resource "google_project_service" "vault_gcp_tests_resources" { 17 | service = "cloudresourcemanager.googleapis.com" 18 | 19 | disable_dependent_services = true 20 | disable_on_destroy = false 21 | } 22 | 23 | resource "google_project_service" "vault_gcp_tests_iam" { 24 | service = "iam.googleapis.com" 25 | 26 | disable_dependent_services = true 27 | disable_on_destroy = false 28 | } 29 | 30 | resource "google_service_account" "vault_gcp_tests" { 31 | account_id = "vault-tester" 32 | display_name = "vault-tester" 33 | } 34 | 35 | resource "google_project_iam_binding" "vault_gcp_tests" { 36 | project = var.GOOGLE_CLOUD_PROJECT_ID 37 | role = "roles/owner" 38 | 39 | members = [ 40 | "serviceAccount:${google_service_account.vault_gcp_tests.email}" 41 | ] 42 | } 43 | 44 | resource "google_project_iam_binding" "vault_gcp_tests_sa_token_creator" { 45 | project = var.GOOGLE_CLOUD_PROJECT_ID 46 | role = "roles/iam.serviceAccountTokenCreator" 47 | 48 | members = [ 49 | "serviceAccount:${google_service_account.vault_gcp_tests.email}" 50 | ] 51 | } 52 | 53 | resource "google_project_iam_binding" "vault_gcp_tests_sa_key_admin" { 54 | project = var.GOOGLE_CLOUD_PROJECT_ID 55 | role = "roles/iam.serviceAccountKeyAdmin" 56 | 57 | members = [ 58 | "serviceAccount:${google_service_account.vault_gcp_tests.email}" 59 | ] 60 | } 61 | 62 | resource "google_service_account_key" "vault_gcp_tests" { 63 | service_account_id = google_service_account.vault_gcp_tests.name 64 | } 65 | 66 | resource "local_file" "vault_gcp_tests" { 67 | content = base64decode(google_service_account_key.vault_gcp_tests.private_key) 68 | filename = "${path.module}/vault-tester.json" 69 | } 70 | 71 | resource "local_file" "setup_environment_file" { 72 | filename = "local_environment_setup.sh" 73 | content = < 0 { 85 | warnings = append(warnings, "ignoring non-empty token_scopes, secret type not access_token") 86 | } 87 | return 88 | } 89 | 90 | func (input *inputParams) parseOkInputBindings(d *framework.FieldData) (warnings []string, err error) { 91 | bRaw, ok := d.GetOk("bindings") 92 | if !ok { 93 | input.hasBindings = false 94 | return nil, nil 95 | } 96 | 97 | rawBindings, castok := bRaw.(string) 98 | if !castok { 99 | return nil, fmt.Errorf("bindings are not a string") 100 | } 101 | 102 | bindings, err := util.ParseBindings(bRaw.(string)) 103 | if err != nil { 104 | return nil, errwrap.Wrapf("unable to parse bindings: {{err}}", err) 105 | } 106 | 107 | input.hasBindings = true 108 | input.rawBindings = rawBindings 109 | input.bindings = bindings 110 | return nil, nil 111 | } 112 | -------------------------------------------------------------------------------- /plugin/gcp_account_resources_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func Test_RoleSetServiceAccountDisplayName(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | input string 19 | want string 20 | }{ 21 | { 22 | name: "display name less than max size", 23 | input: "display-name-that-is-not-truncated", 24 | want: fmt.Sprintf(serviceAccountDisplayNameTmpl, "display-name-that-is-not-truncated"), 25 | }, 26 | { 27 | name: "display name greater than max size", 28 | input: "display-name-that-is-really-long-vault-plugin-secrets-gcp-role-name", 29 | want: fmt.Sprintf(serviceAccountDisplayNameTmpl, "display-name-that-is-really-long-vault-pl43b18db3"), 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | got := roleSetServiceAccountDisplayName(tt.input) 35 | checkDisplayNameLength(t, got) 36 | if got != tt.want { 37 | t.Errorf("roleSetServiceAccountDisplayName() = %v, want %v", got, tt.want) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func TestRetry(t *testing.T) { 44 | if testing.Short() { 45 | t.Skip("skipping test in short mode.") 46 | } 47 | t.Parallel() 48 | t.Run("First try success", func(t *testing.T) { 49 | _, err := retryWithExponentialBackoff(context.Background(), func() (interface{}, bool, error) { 50 | return nil, true, nil 51 | }) 52 | if err != nil { 53 | t.Fatalf("unexpected error: %s", err.Error()) 54 | } 55 | }) 56 | 57 | t.Run("Three retries", func(t *testing.T) { 58 | t.Parallel() 59 | count := 0 60 | 61 | _, err := retryWithExponentialBackoff(context.Background(), func() (interface{}, bool, error) { 62 | count++ 63 | if count >= 3 { 64 | return nil, true, nil 65 | } 66 | return nil, false, nil 67 | }) 68 | if count != 3 { 69 | t.Fatalf("unexpected count: %d", count) 70 | } 71 | 72 | if err != nil { 73 | t.Fatalf("unexpected error: %s", err.Error()) 74 | } 75 | }) 76 | 77 | t.Run("Error on attempt", func(t *testing.T) { 78 | t.Parallel() 79 | _, err := retryWithExponentialBackoff(context.Background(), func() (interface{}, bool, error) { 80 | return nil, true, errors.New("Fail") 81 | }) 82 | if err == nil || !strings.Contains(err.Error(), "Fail") { 83 | t.Fatalf("expected failure error, got: %v", err) 84 | } 85 | }) 86 | 87 | // timeout test 88 | t.Run("Timeout", func(t *testing.T) { 89 | if testing.Short() { 90 | t.Skip("skipping test in short mode.") 91 | } 92 | t.Parallel() 93 | start := time.Now() 94 | 95 | timeout := 10 * time.Second 96 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 97 | defer cancel() 98 | called := 0 99 | _, err := retryWithExponentialBackoff(ctx, func() (interface{}, bool, error) { 100 | called++ 101 | return nil, false, nil 102 | }) 103 | elapsed := time.Now().Sub(start) 104 | if err == nil { 105 | t.Fatalf("expected error, got nil") 106 | } 107 | if called == 0 { 108 | t.Fatalf("retryable function was never called") 109 | } 110 | assertDuration(t, elapsed, timeout, 250*time.Millisecond) 111 | }) 112 | 113 | t.Run("Cancellation", func(t *testing.T) { 114 | t.Parallel() 115 | 116 | ctx, cancel := context.WithCancel(context.Background()) 117 | go func() { 118 | time.Sleep(1 * time.Second) 119 | cancel() 120 | }() 121 | 122 | start := time.Now() 123 | _, err := retryWithExponentialBackoff(ctx, func() (interface{}, bool, error) { 124 | return nil, false, nil 125 | }) 126 | elapsed := time.Now().Sub(start) 127 | assertDuration(t, elapsed, 1*time.Second, 250*time.Millisecond) 128 | 129 | if err == nil { 130 | t.Fatalf("expected err: got nil") 131 | } 132 | underlyingErr := errors.Unwrap(err) 133 | if underlyingErr != context.Canceled { 134 | t.Fatalf("expected %s, got: %v", context.Canceled, err) 135 | } 136 | }) 137 | } 138 | 139 | func checkDisplayNameLength(t *testing.T, displayName string) { 140 | if len(displayName) > serviceAccountDisplayNameMaxLen { 141 | t.Errorf("expected display name to be less than or equal to %v. actual name '%v'", serviceAccountDisplayNameMaxLen, displayName) 142 | } 143 | } 144 | 145 | // assertDuration with a certain amount of flex in the exact value 146 | func assertDuration(t *testing.T, actual, expected, delta time.Duration) { 147 | t.Helper() 148 | 149 | diff := actual - expected 150 | if diff < 0 { 151 | diff = -diff 152 | } 153 | 154 | if diff > delta { 155 | t.Fatalf("Actual duration %s does not equal expected %s with delta %s", actual, expected, delta) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /plugin/iamutil/api_handle.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "strings" 13 | 14 | "github.com/hashicorp/errwrap" 15 | "google.golang.org/api/googleapi" 16 | ) 17 | 18 | type ApiHandle struct { 19 | c *http.Client 20 | userAgent string 21 | } 22 | 23 | func GetApiHandle(client *http.Client, userAgent string) *ApiHandle { 24 | return &ApiHandle{ 25 | c: client, 26 | userAgent: userAgent, 27 | } 28 | } 29 | 30 | func (h *ApiHandle) DoGetRequest(ctx context.Context, r Resource, out interface{}) (err error) { 31 | config := r.GetConfig() 32 | req, err := constructRequest(r, &config.GetMethod, nil) 33 | if err != nil { 34 | return errwrap.Wrapf("Unable to construct Get request: {{err}}", err) 35 | } 36 | return h.doRequest(ctx, req, out) 37 | } 38 | 39 | func (h *ApiHandle) DoSetRequest(ctx context.Context, r Resource, data io.Reader, out interface{}) error { 40 | config := r.GetConfig() 41 | req, err := constructRequest(r, &config.SetMethod, data) 42 | if err != nil { 43 | return errwrap.Wrapf("Unable to construct Set request: {{err}}", err) 44 | } 45 | return h.doRequest(ctx, req, out) 46 | } 47 | 48 | func (h *ApiHandle) doRequest(ctx context.Context, req *http.Request, out interface{}) error { 49 | if req.Header == nil { 50 | req.Header = make(http.Header) 51 | } 52 | if h.userAgent != "" { 53 | req.Header.Set("User-Agent", h.userAgent) 54 | } 55 | 56 | resp, err := h.c.Do(req.WithContext(ctx)) 57 | if err != nil { 58 | return err 59 | } 60 | defer googleapi.CloseBody(resp) 61 | 62 | if err := googleapi.CheckResponse(resp); err != nil { 63 | return err 64 | } 65 | 66 | if err := json.NewDecoder(resp.Body).Decode(out); err != nil { 67 | return errwrap.Wrapf("unable to decode JSON resp to output interface: {{err}}", err) 68 | } 69 | return nil 70 | } 71 | 72 | func constructRequest(r Resource, restMethod *RestMethod, data io.Reader) (*http.Request, error) { 73 | config := r.GetConfig() 74 | if data == nil && config != nil && config.Service == "cloudresourcemanager" { 75 | // In order to support Resource Manager policies with conditional bindings, 76 | // we need to request the policy version of 3. This request parameter is backwards compatible 77 | // and will return version 1 policies if they are not yet updated to version 3. 78 | requestPolicyVersion3 := `{"options": {"requestedPolicyVersion": 3}}` 79 | data = strings.NewReader(requestPolicyVersion3) 80 | } 81 | req, err := http.NewRequest( 82 | restMethod.HttpMethod, 83 | googleapi.ResolveRelative(restMethod.BaseURL, restMethod.Path), 84 | data) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | if req.Header == nil { 90 | req.Header = make(http.Header) 91 | } 92 | if data != nil { 93 | req.Header.Set("Content-Type", "application/json") 94 | } 95 | 96 | relId := r.GetRelativeId() 97 | replacementMap := make(map[string]string) 98 | 99 | if strings.Contains(restMethod.Path, "{+resource}") { 100 | // +resource is used to represent full relative resource name 101 | if len(config.Parameters) == 1 && config.Parameters[0] == "resource" { 102 | relName := "" 103 | tkns := strings.Split(config.TypeKey, "/") 104 | for _, colId := range tkns { 105 | if colName, ok := relId.IdTuples[colId]; ok { 106 | relName += fmt.Sprintf("%s/%s/", colId, colName) 107 | } 108 | } 109 | replacementMap["resource"] = strings.Trim(relName, "/") 110 | } 111 | } else { 112 | for colId, resId := range relId.IdTuples { 113 | rId, ok := config.CollectionReplacementKeys[colId] 114 | if !ok { 115 | return nil, fmt.Errorf("expected value for collection id %s", colId) 116 | } 117 | replacementMap[rId] = resId 118 | } 119 | } 120 | 121 | googleapi.Expand(req.URL, replacementMap) 122 | return req, nil 123 | } 124 | -------------------------------------------------------------------------------- /plugin/iamutil/api_handle_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "testing" 11 | "time" 12 | 13 | "github.com/hashicorp/go-gcp-common/gcputil" 14 | "github.com/hashicorp/go-secure-stdlib/strutil" 15 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 16 | "google.golang.org/api/iam/v1" 17 | "google.golang.org/api/option" 18 | ) 19 | 20 | func TestIamResource_ServiceAccount(t *testing.T) { 21 | createServiceAccount := func(t *testing.T, httpC *http.Client) *IamResource { 22 | iamAdmin, err := iam.NewService(context.Background(), option.WithHTTPClient(httpC)) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | newSa, err := iamAdmin.Projects.ServiceAccounts.Create( 28 | fmt.Sprintf("projects/%s", util.GetTestProject(t)), 29 | &iam.CreateServiceAccountRequest{ 30 | AccountId: fmt.Sprintf("testvaultsa-%d", time.Now().Unix()), 31 | ServiceAccount: &iam.ServiceAccount{ 32 | DisplayName: "test account for Vault IAM Handle test", 33 | }, 34 | }).Do() 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | relId, err := gcputil.ParseRelativeName(newSa.Name) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | rConfig := generatedResources["projects/serviceAccounts"]["iam"]["v1"] 45 | 46 | return &IamResource{ 47 | relativeId: relId, 48 | config: &rConfig, 49 | } 50 | } 51 | 52 | deleteServiceAccount := func(t *testing.T, httpC *http.Client, r *IamResource) { 53 | saName := fmt.Sprintf("projects/%s/serviceAccounts/%s", 54 | r.relativeId.IdTuples["projects"], 55 | r.relativeId.IdTuples["serviceAccounts"]) 56 | iamAdmin, err := iam.NewService(context.Background(), option.WithHTTPClient(httpC)) 57 | if err != nil { 58 | t.Logf("[WARNING] unable to delete test service account %s: %v", saName, err) 59 | return 60 | } 61 | if _, err := iamAdmin.Projects.ServiceAccounts.Delete(saName).Do(); err != nil { 62 | t.Logf("[WARNING] unable to delete test service account %s: %v", saName, err) 63 | } 64 | } 65 | 66 | verifyIamResource_GetSetPolicy(t, "projects/serviceAccounts", createServiceAccount, deleteServiceAccount) 67 | } 68 | 69 | func verifyIamResource_GetSetPolicy(t *testing.T, resourceType string, 70 | getF func(*testing.T, *http.Client) *IamResource, 71 | cleanupF func(*testing.T, *http.Client, *IamResource)) { 72 | 73 | _, creds := util.GetTestCredentials(t) 74 | httpC, err := gcputil.GetHttpClient(creds, iam.CloudPlatformScope) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | r := getF(t, httpC) 80 | defer cleanupF(t, httpC, r) 81 | 82 | h := GetApiHandle(httpC, "") 83 | 84 | p, err := r.GetIamPolicy(context.Background(), h) 85 | if err != nil { 86 | t.Fatalf("could not get IAM Policy for resource type '%s': %v", resourceType, err) 87 | } 88 | 89 | _, newP := p.AddBindings(&PolicyDelta{ 90 | Roles: util.StringSet{"roles/viewer": struct{}{}}, 91 | Email: creds.ClientEmail, 92 | }) 93 | 94 | if p.Version != newP.Version { 95 | t.Fatalf("expected policy version %d after adding bindings, got %d", p.Version, newP.Version) 96 | } 97 | 98 | if err != nil { 99 | t.Fatalf("could not get IAM Policy for resource type '%s': %v", resourceType, err) 100 | } 101 | 102 | changedP, err := r.SetIamPolicy(context.Background(), h, newP) 103 | if err != nil { 104 | t.Fatalf("could not set IAM Policy for resource type '%s': %v", resourceType, err) 105 | } 106 | 107 | actualP, err := r.GetIamPolicy(context.Background(), h) 108 | if err != nil { 109 | t.Fatalf("could not get updated IAM Policy for resource type '%s': %v", resourceType, err) 110 | } 111 | 112 | if actualP.Etag != changedP.Etag { 113 | t.Fatalf("etag mismatch, expected setIAMPolicy to generate new eTag %s, actual: %s", changedP.Etag, actualP.Etag) 114 | } 115 | for _, b := range actualP.Bindings { 116 | if b.Role == "roles/viewer" { 117 | if strutil.StrListContains(b.Members, fmt.Sprintf("serviceAccount:%s", creds.ClientEmail)) { 118 | return 119 | } 120 | } 121 | } 122 | t.Fatal("could not find added in new policy, set unsuccessful") 123 | } 124 | -------------------------------------------------------------------------------- /plugin/iamutil/dataset_resource.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "strings" 12 | 13 | "github.com/hashicorp/errwrap" 14 | "github.com/hashicorp/go-gcp-common/gcputil" 15 | ) 16 | 17 | // NOTE: BigQuery does not conform to the typical REST for IAM policies 18 | // instead it has an access array with bindings on the dataset 19 | // object. https://cloud.google.com/bigquery/docs/reference/rest/v2/datasets#Dataset 20 | type AccessBinding struct { 21 | Role string `json:"role,omitempty"` 22 | UserByEmail string `json:"userByEmail,omitempty"` 23 | GroupByEmail string `json:"groupByEmail,omitempty"` 24 | } 25 | 26 | type Dataset struct { 27 | Access []*AccessBinding `json:"access,omitempty"` 28 | Etag string `json:"etag,omitempty"` 29 | } 30 | 31 | // NOTE: DatasetResource implements IamResource. 32 | // This is because bigquery datasets have their own 33 | // ACLs instead of an IAM policy 34 | type DatasetResource struct { 35 | relativeId *gcputil.RelativeResourceName 36 | config *RestResource 37 | } 38 | 39 | func (r *DatasetResource) GetConfig() *RestResource { 40 | return r.config 41 | } 42 | 43 | func (r *DatasetResource) GetRelativeId() *gcputil.RelativeResourceName { 44 | return r.relativeId 45 | } 46 | 47 | func (r *DatasetResource) GetIamPolicy(ctx context.Context, h *ApiHandle) (*Policy, error) { 48 | var dataset Dataset 49 | if err := h.DoGetRequest(ctx, r, &dataset); err != nil { 50 | return nil, errwrap.Wrapf("unable to get BigQuery Dataset ACL: {{err}}", err) 51 | } 52 | p := datasetAsPolicy(&dataset) 53 | return p, nil 54 | } 55 | 56 | func (r *DatasetResource) SetIamPolicy(ctx context.Context, h *ApiHandle, p *Policy) (*Policy, error) { 57 | var jsonP []byte 58 | ds, err := policyAsDataset(p) 59 | if err != nil { 60 | return nil, err 61 | } 62 | jsonP, err = json.Marshal(ds) 63 | if err != nil { 64 | return nil, err 65 | } 66 | reqJson := fmt.Sprintf(r.config.SetMethod.RequestFormat, jsonP) 67 | if !json.Valid([]byte(reqJson)) { 68 | return nil, fmt.Errorf("request format from generated BigQuery Dataset config invalid JSON: %s", reqJson) 69 | } 70 | 71 | var dataset Dataset 72 | if err := h.DoSetRequest(ctx, r, strings.NewReader(reqJson), &dataset); err != nil { 73 | return nil, errwrap.Wrapf("unable to set BigQuery Dataset ACL: {{err}}", err) 74 | } 75 | policy := datasetAsPolicy(&dataset) 76 | 77 | return policy, nil 78 | } 79 | 80 | func policyAsDataset(p *Policy) (*Dataset, error) { 81 | if p == nil { 82 | return nil, errors.New("Policy cannot be nil") 83 | } 84 | 85 | ds := &Dataset{Etag: p.Etag} 86 | for _, binding := range p.Bindings { 87 | if binding.Condition != nil { 88 | return nil, errors.New("Bigquery Datasets do not support conditional IAM") 89 | } 90 | for _, member := range binding.Members { 91 | var email, iamType string 92 | memberSplit := strings.Split(member, ":") 93 | if len(memberSplit) == 2 { 94 | iamType = memberSplit[0] 95 | email = memberSplit[1] 96 | } else { 97 | email = member 98 | } 99 | 100 | if email != "" { 101 | binding := &AccessBinding{Role: binding.Role} 102 | if iamType == "group" { 103 | binding.GroupByEmail = email 104 | } else { 105 | binding.UserByEmail = email 106 | } 107 | ds.Access = append(ds.Access, binding) 108 | } 109 | } 110 | } 111 | return ds, nil 112 | } 113 | 114 | func datasetAsPolicy(ds *Dataset) *Policy { 115 | if ds == nil { 116 | return &Policy{} 117 | } 118 | 119 | policy := &Policy{Etag: ds.Etag} 120 | bindingMap := make(map[string]*Binding) 121 | for _, accessBinding := range ds.Access { 122 | var iamMember string 123 | 124 | // Role mapping must be applied for datasets in order to properly 125 | // detect when to change bindings (via RemoveBindings()) after a 126 | // modification or deletion occurs. This is due to BigQuery 127 | // access roles accepting both legacy (e.g., OWNER) and current 128 | // (e.g., roles/bigquery.dataOwner) role references. The API will 129 | // only return the legacy format, so this mapping allows us to properly 130 | // diff the current and desired roles to set the access policy. 131 | // 132 | // See the access[].role description in the following document for details 133 | // https://cloud.google.com/bigquery/docs/reference/rest/v2/datasets#Dataset 134 | role := mapLegacyRoles(accessBinding.Role) 135 | 136 | //NOTE: Can either have GroupByEmail or UserByEmail but not both 137 | if accessBinding.GroupByEmail != "" { 138 | iamMember = fmt.Sprintf("group:%s", accessBinding.GroupByEmail) 139 | } else if strings.HasSuffix(accessBinding.UserByEmail, "gserviceaccount.com") { 140 | iamMember = fmt.Sprintf("serviceAccount:%s", accessBinding.UserByEmail) 141 | } else { 142 | iamMember = fmt.Sprintf("user:%s", accessBinding.UserByEmail) 143 | } 144 | if binding, ok := bindingMap[role]; ok { 145 | binding.Members = append(binding.Members, iamMember) 146 | } else { 147 | bindingMap[role] = &Binding{ 148 | Role: role, 149 | Members: []string{iamMember}, 150 | } 151 | } 152 | } 153 | for _, v := range bindingMap { 154 | policy.Bindings = append(policy.Bindings, v) 155 | } 156 | return policy 157 | } 158 | 159 | // mapLegacyRoles returns a current role name given a legacy role name. 160 | // 161 | // The following role mappings will be applied: 162 | // - OWNER -> roles/bigquery.dataOwner 163 | // - WRITER -> roles/bigquery.dataEditor 164 | // - READER -> roles/bigquery.dataViewer 165 | // 166 | // See the access[].role description in the following document for details 167 | // https://cloud.google.com/bigquery/docs/reference/rest/v2/datasets#Dataset 168 | // 169 | // Returns the given role if no mapping applies. 170 | func mapLegacyRoles(role string) string { 171 | switch role { 172 | case "OWNER": 173 | return "roles/bigquery.dataOwner" 174 | case "WRITER": 175 | return "roles/bigquery.dataEditor" 176 | case "READER": 177 | return "roles/bigquery.dataViewer" 178 | default: 179 | return role 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /plugin/iamutil/dataset_resource_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "io/ioutil" 10 | "reflect" 11 | "sort" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/hashicorp/go-gcp-common/gcputil" 16 | ) 17 | 18 | func TestPolicyToDataset(t *testing.T) { 19 | policy, expectedDataset := getTestFixtures() 20 | actualDataset, err := policyAsDataset(policy) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | if !datasetEq(actualDataset, expectedDataset) { 25 | t.Fatalf("%v should be equal to %v", actualDataset, expectedDataset) 26 | } 27 | } 28 | 29 | func TestDatasetToPolicy(t *testing.T) { 30 | expectedPolicy, ds := getTestFixtures() 31 | actualPolicy := datasetAsPolicy(ds) 32 | if !policyEq(actualPolicy, expectedPolicy) { 33 | t.Fatalf("%v should be equal to %v", actualPolicy, expectedPolicy) 34 | } 35 | } 36 | 37 | func TestDatasetResource(t *testing.T) { 38 | expectedP := &Policy{ 39 | Etag: "atag", 40 | Bindings: []*Binding{ 41 | { 42 | Members: []string{"user:myuser@google.com", "serviceAccount:myserviceaccount@iam.gserviceaccount.com"}, 43 | Role: "roles/arole", 44 | }, 45 | { 46 | Members: []string{"user:myuser@google.com", "group:mygroup@google.com"}, 47 | Role: "roles/anotherrole", 48 | }, 49 | }, 50 | } 51 | verifyDatasetResourceWithPolicy(t, expectedP) 52 | } 53 | 54 | func TestConditionalDatasetResource(t *testing.T) { 55 | p := &Policy{ 56 | Etag: "atag", 57 | Version: 3, 58 | Bindings: []*Binding{ 59 | { 60 | Members: []string{"user:myuser@google.com", "serviceAccount:myserviceaccount@iam.gserviceaccount.com"}, 61 | Role: "roles/arole", 62 | Condition: &Condition{ 63 | Title: "test", 64 | Description: "", 65 | Expression: "a==b", 66 | }, 67 | }, 68 | { 69 | Members: []string{"user:myuser@google.com"}, 70 | Role: "roles/anotherrole", 71 | }, 72 | }, 73 | } 74 | 75 | _, err := policyAsDataset(p) 76 | if err == nil { 77 | t.Fatalf("Datasets do not support conditions, but error was not triggered") 78 | } 79 | } 80 | 81 | func verifyDatasetResourceWithPolicy(t *testing.T, expectedP *Policy) { 82 | r := testResource() 83 | 84 | getR, err := constructRequest(r, &r.config.GetMethod, nil) 85 | if err != nil { 86 | t.Fatalf("Could not construct GetIamPolicyRequest: %v", err) 87 | } 88 | expectedURLBase := "https://bigquery.googleapis.com/bigquery/v2/projects/project/datasets/dataset" 89 | if getR.URL.String() != expectedURLBase { 90 | t.Fatalf("expected get request URL %s, got %s", expectedURLBase, getR.URL.String()) 91 | } 92 | if getR.Method != "GET" { 93 | t.Fatalf("expected get request method %s, got %s", "GET", getR.Method) 94 | } 95 | if getR.Body != nil { 96 | data, err := ioutil.ReadAll(getR.Body) 97 | t.Fatalf("expected nil get body, actual non-nil body.Read returns %s %v", string(data), err) 98 | } 99 | 100 | ds, err := policyAsDataset(expectedP) 101 | if err != nil { 102 | t.Fatalf("Could not convert policy to dataset: %v", err) 103 | } 104 | 105 | jsonP, err := json.Marshal(ds) 106 | if err != nil { 107 | t.Fatalf("Could not json marshal expected policy: %v", err) 108 | } 109 | 110 | reqJson := fmt.Sprintf(r.config.SetMethod.RequestFormat, jsonP) 111 | if !json.Valid([]byte(reqJson)) { 112 | t.Fatalf("Could not format expected policy: %v", err) 113 | } 114 | 115 | setR, err := constructRequest(r, &r.config.SetMethod, strings.NewReader(reqJson)) 116 | if err != nil { 117 | t.Fatalf("Could not construct SetIamPolicyRequest: %v", err) 118 | } 119 | 120 | if setR.URL.String() != expectedURLBase { 121 | t.Fatalf("expected set request URL %s, got %s", expectedURLBase, setR.URL.String()) 122 | } 123 | if setR.Method != "PATCH" { 124 | t.Fatalf("expected set request method %s, got %s", "PATCH", setR.Method) 125 | } 126 | if setR.Header.Get("Content-Type") != "application/json" { 127 | t.Fatalf("expected `Content Type = application/json` header in set request, headers: %+v", setR.Header) 128 | } 129 | if setR.Body == nil { 130 | t.Fatalf("expected non-nil set body, actually nil") 131 | } 132 | data, err := ioutil.ReadAll(setR.Body) 133 | if err != nil { 134 | t.Fatalf("unable to read data from set request: %v", err) 135 | } 136 | 137 | actual := struct { 138 | D *Dataset `json:"access,omitempty"` 139 | P *Policy `json:"policy,omitempty"` 140 | }{} 141 | if err := json.Unmarshal(data, &actual.D); err != nil { 142 | t.Fatalf("unable to read policy from set request body: %v", err) 143 | } 144 | actual.P = datasetAsPolicy(actual.D) 145 | if actual.P.Etag != expectedP.Etag { 146 | t.Fatalf("mismatch set request policy, expected %s, got %s", expectedP.Etag, actual.P.Etag) 147 | } 148 | 149 | if len(actual.P.Bindings) != len(expectedP.Bindings) { 150 | t.Fatalf("mismatch set request policy bindings length, expected %+v, got %+v", expectedP.Bindings, actual.P.Bindings) 151 | } 152 | 153 | if !policyEq(expectedP, actual.P) { 154 | exBytes, _ := json.Marshal(expectedP) 155 | acBytes, _ := json.Marshal(actual.P) 156 | t.Fatalf("Expected policy %v. Got policy %v", string(exBytes), string(acBytes)) 157 | } 158 | } 159 | 160 | // Necessary due to using a map to convert between dataset/policy 161 | // since maps do not retain order 162 | func policyEq(p1 *Policy, p2 *Policy) bool { 163 | sort.SliceStable(p1.Bindings, func(i, j int) bool { return p1.Bindings[i].Role < p1.Bindings[j].Role }) 164 | sort.SliceStable(p2.Bindings, func(i, j int) bool { return p2.Bindings[i].Role < p2.Bindings[j].Role }) 165 | return reflect.DeepEqual(p1, p2) 166 | } 167 | 168 | func datasetEq(d1 *Dataset, d2 *Dataset) bool { 169 | sort.SliceStable(d1.Access, func(i, j int) bool { return d1.Access[i].Role < d1.Access[j].Role }) 170 | sort.SliceStable(d2.Access, func(i, j int) bool { return d2.Access[i].Role < d2.Access[j].Role }) 171 | return reflect.DeepEqual(*d1, *d2) 172 | } 173 | 174 | func getTestFixtures() (*Policy, *Dataset) { 175 | policy := &Policy{ 176 | Etag: "atag", 177 | Bindings: []*Binding{ 178 | &Binding{ 179 | Members: []string{ 180 | "serviceAccount:foo@my-projectiam.gserviceaccount.com", 181 | "serviceAccount:bar@my-projectiam.gserviceaccount.com", 182 | }, 183 | Role: "roles/bigquery.dataViewer", 184 | }, 185 | &Binding{ 186 | Members: []string{ 187 | "serviceAccount:baz@my-projectiam.gserviceaccount.com", 188 | }, 189 | Role: "roles/bigquery.dataOwner", 190 | }, 191 | }, 192 | } 193 | ds := &Dataset{ 194 | Etag: "atag", 195 | Access: []*AccessBinding{ 196 | &AccessBinding{ 197 | Role: "roles/bigquery.dataViewer", 198 | UserByEmail: "foo@my-projectiam.gserviceaccount.com", 199 | }, 200 | &AccessBinding{ 201 | Role: "roles/bigquery.dataViewer", 202 | UserByEmail: "bar@my-projectiam.gserviceaccount.com", 203 | }, 204 | &AccessBinding{ 205 | Role: "roles/bigquery.dataOwner", 206 | UserByEmail: "baz@my-projectiam.gserviceaccount.com", 207 | }, 208 | }, 209 | } 210 | return policy, ds 211 | } 212 | 213 | func testResource() *DatasetResource { 214 | return &DatasetResource{ 215 | relativeId: &gcputil.RelativeResourceName{ 216 | Name: "datasets", 217 | TypeKey: "projects/datasets", 218 | IdTuples: map[string]string{ 219 | "projects": "project", 220 | "datasets": "dataset", 221 | }, 222 | OrderedCollectionIds: []string{"projects", "datasets"}, 223 | }, 224 | config: &RestResource{ 225 | Name: "datasets", 226 | TypeKey: "projects/datasets", 227 | Service: "bigquery", 228 | IsPreferredVersion: true, 229 | Parameters: []string{"resource"}, 230 | CollectionReplacementKeys: map[string]string{}, 231 | GetMethod: RestMethod{ 232 | HttpMethod: "GET", 233 | BaseURL: "https://bigquery.googleapis.com", 234 | Path: "bigquery/v2/{+resource}", 235 | }, 236 | SetMethod: RestMethod{ 237 | HttpMethod: "PATCH", 238 | BaseURL: "https://bigquery.googleapis.com", 239 | Path: "bigquery/v2/{+resource}", 240 | RequestFormat: "%s", 241 | }, 242 | }, 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /plugin/iamutil/iam_policy.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 10 | ) 11 | 12 | const ( 13 | ServiceAccountMemberTmpl = "serviceAccount:%s" 14 | ) 15 | 16 | type Policy struct { 17 | Bindings []*Binding `json:"bindings,omitempty"` 18 | Etag string `json:"etag,omitempty"` 19 | Version int `json:"version,omitempty"` 20 | } 21 | 22 | type Binding struct { 23 | Members []string `json:"members,omitempty"` 24 | Role string `json:"role,omitempty"` 25 | Condition *Condition `json:"condition,omitempty"` 26 | } 27 | 28 | type Condition struct { 29 | Title string `json:"title,omitempty"` 30 | Description string `json:"description,omitempty"` 31 | Expression string `json:"expression,omitempty"` 32 | } 33 | 34 | type PolicyDelta struct { 35 | Roles util.StringSet 36 | Email string 37 | } 38 | 39 | func (p *Policy) AddBindings(toAdd *PolicyDelta) (changed bool, updated *Policy) { 40 | return p.ChangeBindings(toAdd, nil) 41 | } 42 | 43 | func (p *Policy) RemoveBindings(toRemove *PolicyDelta) (changed bool, updated *Policy) { 44 | return p.ChangeBindings(nil, toRemove) 45 | } 46 | 47 | func (p *Policy) ChangeBindings(toAdd *PolicyDelta, toRemove *PolicyDelta) (changed bool, updated *Policy) { 48 | if toAdd == nil && toRemove == nil { 49 | return false, p 50 | } 51 | 52 | var toAddMem, toRemoveMem string 53 | if toAdd != nil { 54 | toAddMem = fmt.Sprintf(ServiceAccountMemberTmpl, toAdd.Email) 55 | } 56 | if toRemove != nil { 57 | toRemoveMem = fmt.Sprintf(ServiceAccountMemberTmpl, toRemove.Email) 58 | } 59 | 60 | changed = false 61 | 62 | newBindings := make([]*Binding, 0, len(p.Bindings)) 63 | alreadyAdded := make(util.StringSet) 64 | 65 | for _, bind := range p.Bindings { 66 | memberSet := util.ToSet(bind.Members) 67 | 68 | if toAdd != nil { 69 | if toAdd.Roles.Includes(bind.Role) { 70 | changed = true 71 | alreadyAdded.Add(bind.Role) 72 | memberSet.Add(toAddMem) 73 | } 74 | } 75 | 76 | if toRemove != nil { 77 | if toRemove.Roles.Includes(bind.Role) { 78 | if memberSet.Includes(toRemoveMem) { 79 | changed = true 80 | delete(memberSet, toRemoveMem) 81 | } 82 | } 83 | } 84 | 85 | if len(memberSet) > 0 { 86 | newBindings = append(newBindings, &Binding{ 87 | Role: bind.Role, 88 | Members: memberSet.ToSlice(), 89 | Condition: bind.Condition, 90 | }) 91 | } 92 | } 93 | 94 | if toAdd != nil { 95 | for r := range toAdd.Roles { 96 | if !alreadyAdded.Includes(r) { 97 | changed = true 98 | newBindings = append(newBindings, &Binding{ 99 | Role: r, 100 | Members: []string{toAddMem}, 101 | }) 102 | } 103 | } 104 | } 105 | 106 | if changed { 107 | return true, &Policy{ 108 | Bindings: newBindings, 109 | Etag: p.Etag, 110 | Version: p.Version, 111 | } 112 | } 113 | return false, p 114 | } 115 | -------------------------------------------------------------------------------- /plugin/iamutil/iam_policy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 10 | ) 11 | 12 | func TestConditionsAfterIamPolicyUpdate(t *testing.T) { 13 | p := &Policy{ 14 | Version: 3, 15 | Etag: "atag", 16 | Bindings: []*Binding{ 17 | { 18 | Members: []string{"user:myuser@google.com", "serviceAccount:myserviceaccount@iam.gserviceaccount.com"}, 19 | Role: "roles/arole", 20 | }, 21 | { 22 | Members: []string{"user:myuser@google.com"}, 23 | Role: "roles/anotherrole", 24 | Condition: &Condition{ 25 | Title: "Temporary", 26 | Description: "some description", 27 | Expression: "some expression", 28 | }, 29 | }, 30 | { 31 | Members: []string{"user:myuser@google.com"}, 32 | Role: "roles/yetanotherrole", 33 | Condition: &Condition{ 34 | Title: "Temporary", 35 | Description: "some description", 36 | Expression: "some expression", 37 | }, 38 | }, 39 | }, 40 | } 41 | 42 | d := &PolicyDelta{ 43 | Roles: util.ToSet([]string{"roles/brole", "roles/crole"}), 44 | Email: "myuser@google.com", 45 | } 46 | 47 | _, np := p.AddBindings(d) 48 | 49 | conditions_before := 0 50 | for _, binding := range p.Bindings { 51 | if binding.Condition != nil { 52 | conditions_before += 1 53 | } 54 | } 55 | 56 | conditions_after := 0 57 | for _, binding := range np.Bindings { 58 | if binding.Condition != nil { 59 | conditions_after += 1 60 | } 61 | } 62 | 63 | if conditions_after != conditions_before { 64 | t.Fatalf("number of conditions changed after adding bindings: before - %v now - %v", conditions_before, conditions_after) 65 | } 66 | 67 | _, np = p.RemoveBindings(d) 68 | 69 | conditions_after = 0 70 | for _, binding := range np.Bindings { 71 | if binding.Condition != nil { 72 | conditions_after += 1 73 | } 74 | } 75 | 76 | if conditions_after != conditions_before { 77 | t.Fatalf("number of conditions changed after removing bindings: before - %v now - %v", conditions_before, conditions_after) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /plugin/iamutil/iam_resource.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "strings" 11 | 12 | "github.com/hashicorp/errwrap" 13 | "github.com/hashicorp/go-gcp-common/gcputil" 14 | ) 15 | 16 | // IamResource implements Resource. 17 | type IamResource struct { 18 | relativeId *gcputil.RelativeResourceName 19 | config *RestResource 20 | } 21 | 22 | func (r *IamResource) GetConfig() *RestResource { 23 | return r.config 24 | } 25 | 26 | func (r *IamResource) GetRelativeId() *gcputil.RelativeResourceName { 27 | return r.relativeId 28 | } 29 | 30 | func (r *IamResource) GetIamPolicy(ctx context.Context, h *ApiHandle) (*Policy, error) { 31 | var p Policy 32 | if err := h.DoGetRequest(ctx, r, &p); err != nil { 33 | return nil, errwrap.Wrapf("unable to get policy: {{err}}", err) 34 | } 35 | return &p, nil 36 | } 37 | 38 | func (r *IamResource) SetIamPolicy(ctx context.Context, h *ApiHandle, p *Policy) (*Policy, error) { 39 | jsonP, err := json.Marshal(p) 40 | if err != nil { 41 | return nil, err 42 | } 43 | reqJson := fmt.Sprintf(r.config.SetMethod.RequestFormat, jsonP) 44 | if !json.Valid([]byte(reqJson)) { 45 | return nil, fmt.Errorf("request format from generated IAM config invalid JSON: %s", reqJson) 46 | } 47 | 48 | var policy Policy 49 | if err := h.DoSetRequest(ctx, r, strings.NewReader(reqJson), &policy); err != nil { 50 | return nil, errwrap.Wrapf("unable to set policy: {{err}}", err) 51 | } 52 | return &policy, nil 53 | } 54 | -------------------------------------------------------------------------------- /plugin/iamutil/internal/resource_config_template: -------------------------------------------------------------------------------- 1 | {{define "main"}} 2 | // Copyright (c) HashiCorp, Inc. 3 | // SPDX-License-Identifier: MPL-2.0 4 | 5 | // THIS FILE IS AUTOGENERATED USING go generate. DO NOT EDIT. 6 | package iamutil 7 | 8 | func GetEnabledResources() GeneratedResources { 9 | return generatedResources 10 | } 11 | 12 | var generatedResources = map[string]map[string]map[string]RestResource { 13 | {{ range $typeKey,$serviceMap := . -}} 14 | "{{$typeKey}}": { 15 | {{ range $service, $versionMap := . -}} 16 | "{{$service}}": { 17 | {{ range $version, $resource := $versionMap -}} 18 | "{{$version}}": {{template "rest_resource" $resource}}, 19 | {{ end -}} 20 | }, 21 | {{ end -}} 22 | }, 23 | {{ end -}} 24 | } 25 | 26 | {{end}} 27 | 28 | {{ define "rest_resource" -}} 29 | RestResource{ 30 | Name: "{{.Name}}", 31 | TypeKey: "{{.TypeKey}}", 32 | Service: "{{.Service}}", 33 | IsPreferredVersion: {{.IsPreferredVersion}}, 34 | Parameters: []string{ {{- range $idx, $v := .Parameters -}}"{{$v}}",{{- end -}} }, 35 | CollectionReplacementKeys: map[string]string{ {{- range $k, $v := .CollectionReplacementKeys }} 36 | "{{$k}}":"{{$v}}", 37 | {{- end}} 38 | }, 39 | GetMethod: {{template "get_rest_method" .GetMethod }}, 40 | SetMethod: {{template "set_rest_method" .SetMethod }}, 41 | } 42 | 43 | {{- end}} 44 | 45 | {{define "get_rest_method" -}} 46 | RestMethod { 47 | HttpMethod: "{{ .HttpMethod }}", 48 | BaseURL: "{{ .BaseURL }}", 49 | Path: "{{ .Path }}", 50 | } 51 | {{- end}} 52 | 53 | {{define "set_rest_method" -}} 54 | RestMethod { 55 | HttpMethod: "{{ .HttpMethod }}", 56 | BaseURL: "{{ .BaseURL }}", 57 | Path: "{{ .Path }}", 58 | RequestFormat: `{{ .RequestFormat }}`, 59 | } 60 | {{- end}} 61 | -------------------------------------------------------------------------------- /plugin/iamutil/internal/resource_overrides.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/iamutil" 8 | ) 9 | 10 | var resourceOverrides = map[string]map[string]map[string]iamutil.RestResource{ 11 | "projects/datasets": { 12 | "bigquery": { 13 | "v2": iamutil.RestResource{ 14 | Name: "datasets", 15 | TypeKey: "projects/datasets", 16 | Service: "bigquery", 17 | IsPreferredVersion: true, 18 | Parameters: []string{"resource"}, 19 | CollectionReplacementKeys: map[string]string{}, 20 | GetMethod: iamutil.RestMethod{ 21 | HttpMethod: "GET", 22 | BaseURL: "https://bigquery.googleapis.com", 23 | Path: "bigquery/v2/{+resource}", 24 | }, 25 | SetMethod: iamutil.RestMethod{ 26 | HttpMethod: "PATCH", 27 | BaseURL: "https://bigquery.googleapis.com", 28 | // NOTE: the bigquery portion of the path needs to be in 29 | // the version since googleapis removes it from the 30 | // BaseURL when resolving 31 | Path: "bigquery/v2/{+resource}", 32 | RequestFormat: "%s", 33 | }, 34 | }, 35 | }, 36 | }, 37 | "projects/datasets/tables": { 38 | "bigquery": { 39 | "v2": iamutil.RestResource{ 40 | Name: "tables", 41 | TypeKey: "projects/datasets/tables", 42 | Service: "bigquery", 43 | IsPreferredVersion: true, 44 | Parameters: []string{"resource"}, 45 | CollectionReplacementKeys: map[string]string{}, 46 | GetMethod: iamutil.RestMethod{ 47 | HttpMethod: "GET", 48 | BaseURL: "https://bigquery.googleapis.com", 49 | Path: "bigquery/v2/{+resource}:getIamPolicy", 50 | }, 51 | SetMethod: iamutil.RestMethod{ 52 | HttpMethod: "PATCH", 53 | BaseURL: "https://bigquery.googleapis.com", 54 | // NOTE: the bigquery portion of the path needs to be in 55 | // the version since googleapis removes it from the 56 | // BaseURL when resolving 57 | Path: "bigquery/v2/{+resource}:setIamPolicy", 58 | RequestFormat: `{"policy": %s}`, 59 | }, 60 | }, 61 | }, 62 | }, 63 | "projects/datasets/routines": { 64 | "bigquery": { 65 | "v2": iamutil.RestResource{ 66 | Name: "routines", 67 | TypeKey: "projects/datasets/routines", 68 | Service: "bigquery", 69 | IsPreferredVersion: true, 70 | Parameters: []string{"resource"}, 71 | CollectionReplacementKeys: map[string]string{}, 72 | GetMethod: iamutil.RestMethod{ 73 | HttpMethod: "GET", 74 | BaseURL: "https://bigquery.googleapis.com", 75 | Path: "bigquery/v2/{+resource}:getIamPolicy", 76 | }, 77 | SetMethod: iamutil.RestMethod{ 78 | HttpMethod: "PATCH", 79 | BaseURL: "https://bigquery.googleapis.com", 80 | // NOTE: the bigquery portion of the path needs to be in 81 | // the version since googleapis removes it from the 82 | // BaseURL when resolving 83 | Path: "bigquery/v2/{+resource}:setIamPolicy", 84 | RequestFormat: `{"policy": %s}`, 85 | }, 86 | }, 87 | }, 88 | }, 89 | } 90 | 91 | var resourceSkips = map[string]map[string]struct{}{ 92 | "poly": {"v1": {}}, // Advertised as available at https://poly.googleapis.com/$discovery/rest?alt=json&prettyPrint=false&version=v1, but returns a 502 93 | "realtimebidding": {"v1alpha": {}}, // Advertised as available at https://realtimebidding.googleapis.com/$discovery/rest?alt=json&prettyPrint=false&version=v1alpha, but returns a 404 94 | } 95 | -------------------------------------------------------------------------------- /plugin/iamutil/resource.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/go-gcp-common/gcputil" 10 | ) 11 | 12 | // Resource handles constructing HTTP requests for getting and 13 | // setting IAM policies. 14 | type Resource interface { 15 | GetIamPolicy(context.Context, *ApiHandle) (*Policy, error) 16 | SetIamPolicy(context.Context, *ApiHandle, *Policy) (*Policy, error) 17 | GetConfig() *RestResource 18 | GetRelativeId() *gcputil.RelativeResourceName 19 | } 20 | 21 | type RestResource struct { 22 | // Name is the base name of the resource 23 | // i.e. for a GCE instance: "instance" 24 | Name string 25 | 26 | // TypeKey is the identifying path for the resource, or 27 | // the RESTful resource identifier without resource IDs 28 | // i.e. For a GCE instance: "projects/zones/instances" 29 | TypeKey string 30 | 31 | // Service is the name of the service this resource belongs to. 32 | Service string 33 | 34 | // IsPreferredVersion is true if this version of the API/resource is preferred. 35 | IsPreferredVersion bool 36 | 37 | // HTTP metadata for getting Policy data in GCP 38 | GetMethod RestMethod 39 | 40 | // HTTP metadata for setting Policy data in GCP 41 | SetMethod RestMethod 42 | 43 | // Ordered parameters to be replaced in method paths 44 | Parameters []string 45 | 46 | // Mapping of collection ids onto the parameter to be replaced 47 | CollectionReplacementKeys map[string]string 48 | } 49 | 50 | type RestMethod struct { 51 | HttpMethod string 52 | BaseURL string 53 | Path string 54 | RequestFormat string 55 | } 56 | -------------------------------------------------------------------------------- /plugin/iamutil/resource_parser.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:generate go run github.com/hashicorp/vault-plugin-secrets-gcp/plugin/iamutil/internal/ 5 | package iamutil 6 | 7 | import ( 8 | "fmt" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/hashicorp/go-gcp-common/gcputil" 13 | ) 14 | 15 | const ( 16 | resourceParsingErrorTmpl = `invalid resource "%s": %v` 17 | errorMultipleServices = `please provide a self-link or full resource name for non-service-unique resource type` 18 | errorMultipleVersions = `please provide a self-link with version instead; multiple versions of this resource exist, all non-preferred` 19 | ) 20 | 21 | // ResourceParser handles parsing resource ID and REST 22 | // config from a given resource ID or name. 23 | type ResourceParser interface { 24 | Parse(string) (Resource, error) 25 | } 26 | 27 | // GeneratedResources implements ResourceParser - a value 28 | // is generated using internal/generate_iam.go 29 | type GeneratedResources map[string]map[string]map[string]RestResource 30 | 31 | func getResourceFromVersions(rawName string, versionMap map[string]RestResource) (*RestResource, error) { 32 | possibleVer := make([]string, 0, len(versionMap)) 33 | for v, config := range versionMap { 34 | if config.IsPreferredVersion || len(versionMap) == 1 { 35 | return &config, nil 36 | } 37 | if strings.Contains(v, "alpha") { 38 | continue 39 | } 40 | if strings.Contains(v, "beta") { 41 | continue 42 | } 43 | possibleVer = append(possibleVer, v) 44 | } 45 | if len(possibleVer) == 1 { 46 | cfg := versionMap[possibleVer[0]] 47 | return &cfg, nil 48 | } 49 | return nil, fmt.Errorf(resourceParsingErrorTmpl, rawName, errorMultipleVersions) 50 | } 51 | 52 | func (apis GeneratedResources) GetRestConfig(rawName string, fullName *gcputil.FullResourceName, prefix string) (*RestResource, error) { 53 | relName := fullName.RelativeResourceName 54 | if relName == nil { 55 | return nil, fmt.Errorf(resourceParsingErrorTmpl, rawName, fmt.Errorf("relative name does not exist: %s", rawName)) 56 | } 57 | 58 | serviceMap, ok := apis[relName.TypeKey] 59 | if !ok { 60 | return nil, fmt.Errorf(resourceParsingErrorTmpl, rawName, fmt.Errorf("unsupported resource type: %s", relName.TypeKey)) 61 | } 62 | 63 | if len(prefix) > 0 { 64 | for _, versionMap := range serviceMap { 65 | for _, config := range versionMap { 66 | if strings.HasPrefix(config.GetMethod.BaseURL+config.GetMethod.Path, prefix) { 67 | return &config, nil 68 | } 69 | } 70 | } 71 | return nil, fmt.Errorf(resourceParsingErrorTmpl, rawName, fmt.Errorf("unsupported service/version for resource with prefix %s", prefix)) 72 | } else if len(fullName.Service) > 0 { 73 | versionMap, ok := serviceMap[fullName.Service] 74 | if !ok { 75 | return nil, fmt.Errorf(resourceParsingErrorTmpl, rawName, fmt.Errorf("unsupported service %s for resource %s", fullName.Service, relName.TypeKey)) 76 | } 77 | 78 | return getResourceFromVersions(rawName, versionMap) 79 | } else if len(serviceMap) == 1 { 80 | for _, versionMap := range serviceMap { 81 | return getResourceFromVersions(rawName, versionMap) 82 | } 83 | } 84 | return nil, fmt.Errorf(resourceParsingErrorTmpl, rawName, errorMultipleServices) 85 | } 86 | 87 | func (apis GeneratedResources) Parse(rawName string) (Resource, error) { 88 | rUrl, err := url.Parse(rawName) 89 | if err != nil { 90 | return nil, fmt.Errorf(`resource "%s" is invalid URI`, rawName) 91 | } 92 | 93 | var relName *gcputil.RelativeResourceName 94 | var prefix, service string 95 | if rUrl.Scheme != "" { 96 | selfLink, err := gcputil.ParseProjectResourceSelfLink(rawName) 97 | if err != nil { 98 | return nil, err 99 | } 100 | relName = selfLink.RelativeResourceName 101 | prefix = selfLink.Prefix 102 | } else if rUrl.Host != "" { 103 | fullName, err := gcputil.ParseFullResourceName(rawName) 104 | if err != nil { 105 | return nil, err 106 | } 107 | relName = fullName.RelativeResourceName 108 | service = fullName.Service 109 | } else { 110 | relName, err = gcputil.ParseRelativeName(rawName) 111 | if err != nil { 112 | return nil, err 113 | } 114 | } 115 | 116 | if relName == nil { 117 | return nil, fmt.Errorf(resourceParsingErrorTmpl, rawName, "nil relative name") 118 | } 119 | 120 | cfg, err := apis.GetRestConfig( 121 | rawName, 122 | &gcputil.FullResourceName{ 123 | Service: service, 124 | RelativeResourceName: relName, 125 | }, 126 | prefix) 127 | if err != nil { 128 | return nil, err 129 | } 130 | switch cfg.TypeKey { 131 | case "projects/datasets": 132 | return &DatasetResource{relativeId: relName, config: cfg}, nil 133 | default: 134 | return &IamResource{relativeId: relName, config: cfg}, nil 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /plugin/iamutil/resource_parser_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package iamutil 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | 12 | "net/url" 13 | 14 | "github.com/hashicorp/errwrap" 15 | ) 16 | 17 | var letters = "ABCDEFGHIJKLMNOP" 18 | 19 | func TestEnabledResources_RelativeName(t *testing.T) { 20 | enabledApis := GetEnabledResources() 21 | 22 | for resourceType, services := range generatedResources { 23 | if resourceType == "" { 24 | continue 25 | } 26 | 27 | testRelName := getFakeId(resourceType) 28 | 29 | var needsService = len(services) > 1 30 | var needsVersion bool 31 | if !needsService { 32 | for _, versions := range services { 33 | needsVersion = expectVersionError(versions) 34 | break 35 | } 36 | } 37 | 38 | resource, err := enabledApis.Parse(testRelName) 39 | if !needsService && !needsVersion { 40 | if err != nil { 41 | t.Errorf("failed to get resource for relative resource name %q (type: %q): %s", testRelName, resourceType, err) 42 | } 43 | 44 | if resource != nil { 45 | if err = verifyResource(resourceType, resource); err != nil { 46 | t.Errorf("could not verify resource for relative resource name %q: %sv", testRelName, err) 47 | } 48 | } 49 | } else if resource != nil || err == nil { 50 | t.Errorf("expected error for using relative resource name %q (type: %q), got resource:\n %v\n", testRelName, resourceType, resource) 51 | continue 52 | } 53 | } 54 | } 55 | 56 | func TestEnabledResources_FullName(t *testing.T) { 57 | enabledApis := GetEnabledResources() 58 | 59 | for resourceType, services := range generatedResources { 60 | if resourceType == "" { 61 | continue 62 | } 63 | 64 | for service, versions := range services { 65 | testFullName := fmt.Sprintf("//%s.googleapis.com/%s", service, getFakeId(resourceType)) 66 | resource, err := enabledApis.Parse(testFullName) 67 | 68 | if !expectVersionError(versions) { 69 | if err != nil { 70 | t.Errorf("failed to get resource for full resource name %s (type: %s): %v", testFullName, resourceType, err) 71 | continue 72 | } 73 | if err = verifyResource(resourceType, resource); err != nil { 74 | t.Errorf("could not verify resource for relative resource name %s: %v", testFullName, err) 75 | continue 76 | } 77 | } else if resource != nil || err == nil { 78 | t.Errorf("expected error for using full resource name %s (type: %s), got resource:\n %v\n", testFullName, resourceType, resource) 79 | continue 80 | } 81 | } 82 | } 83 | } 84 | 85 | func constructSelfLink(relName string, cfg RestResource) (string, error) { 86 | reqUrl := cfg.GetMethod.BaseURL + cfg.GetMethod.Path 87 | 88 | _, err := url.Parse(reqUrl) 89 | if err != nil { 90 | return "", fmt.Errorf("unexpected request URL in resource GetMethod - %s is not a URL", reqUrl) 91 | } 92 | 93 | fullResourceI := strings.Index(reqUrl, "/{+resource}") 94 | if fullResourceI >= 0 { 95 | return reqUrl[:fullResourceI] + relName, nil 96 | } 97 | 98 | endI := strings.Index(reqUrl, "/{") 99 | if endI < 1 { 100 | return "", fmt.Errorf("unexpected request URL in resource does not have parameter to be replaced: %s", reqUrl) 101 | } 102 | startI := strings.LastIndex(reqUrl, "/") 103 | if startI < 0 { 104 | return "", fmt.Errorf("unexpected request URL in resource does not have proper parameter to be replaced: %s", reqUrl) 105 | } 106 | return reqUrl[:endI] + relName, nil 107 | } 108 | 109 | func TestEnabledIamResources_SelfLink(t *testing.T) { 110 | enabledApis := GetEnabledResources() 111 | 112 | for resourceType, services := range generatedResources { 113 | for _, versions := range services { 114 | for _, cfg := range versions { 115 | relName := getFakeId(resourceType) 116 | testSelfLink, err := constructSelfLink(relName, cfg) 117 | if err != nil { 118 | t.Error(err) 119 | continue 120 | } 121 | isProjectLevel := strings.HasPrefix(relName, "projects/") 122 | if isProjectLevel && strings.HasSuffix(cfg.GetMethod.BaseURL, "projects/") { 123 | testSelfLink = cfg.GetMethod.BaseURL + strings.TrimPrefix(relName, "projects/") 124 | } 125 | 126 | resource, err := enabledApis.Parse(testSelfLink) 127 | if isProjectLevel { 128 | if err != nil { 129 | t.Errorf("failed to get resource for self link %s (type: %s): %v", testSelfLink, resourceType, err) 130 | } 131 | if r, ok := resource.(*IamResource); ok { 132 | if err = verifyResource(resourceType, r); err != nil { 133 | t.Errorf("could not verify resource for self link %s: %v", testSelfLink, err) 134 | } 135 | } 136 | } else if resource != nil || err == nil { 137 | t.Errorf("expected error for using self link %s (type: %s), got resource:\n %v\n", testSelfLink, resourceType, resource) 138 | continue 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | func expectVersionError(versions map[string]RestResource) bool { 146 | if len(versions) == 1 { 147 | return false 148 | } 149 | verCnt := 0 150 | for versionName, cfg := range versions { 151 | if cfg.IsPreferredVersion { 152 | return false 153 | } 154 | if strings.Contains(versionName, "alpha") || strings.Contains(versionName, "beta") { 155 | continue 156 | } 157 | verCnt++ 158 | } 159 | return verCnt != 1 160 | } 161 | 162 | func verifyHttpMethod(typeKey string, m *RestMethod) error { 163 | if len(m.Path) == 0 { 164 | return fmt.Errorf("empty http method path") 165 | } 166 | 167 | if m.BaseURL == "" { 168 | return fmt.Errorf("empty base url for method (typeKey %s)", typeKey) 169 | } 170 | if m.Path == "" { 171 | return fmt.Errorf("empty path for method (typeKey %s)", typeKey) 172 | } 173 | 174 | fullUrl := m.BaseURL + m.Path 175 | u, err := url.Parse(fullUrl) 176 | if err != nil { 177 | return fmt.Errorf("invalid method URL for resource %s: %s", typeKey, fullUrl) 178 | } 179 | if u.Scheme == "" { 180 | return fmt.Errorf("invalid method URL for resource %s is missing scheme: %s", typeKey, fullUrl) 181 | } 182 | if u.Host == "" { 183 | return fmt.Errorf("invalid method URL for resource %s is missing host: %s", typeKey, fullUrl) 184 | } 185 | if u.Path == "" { 186 | return fmt.Errorf("invalid method URL for resource %s is missing path: %s", typeKey, fullUrl) 187 | } 188 | 189 | switch m.HttpMethod { 190 | case http.MethodGet: 191 | case http.MethodPost: 192 | case http.MethodPut: 193 | case http.MethodPatch: 194 | return nil 195 | default: 196 | return fmt.Errorf("unexpected HttpMethod %s", m.HttpMethod) 197 | } 198 | 199 | return nil 200 | } 201 | 202 | func TestIamEnabledResources_ValidateGeneratedConfig(t *testing.T) { 203 | for typeKey, services := range generatedResources { 204 | for service, versions := range services { 205 | for ver, cfg := range versions { 206 | if cfg.Service != service { 207 | t.Errorf("mismatch service config name '%s' for resources[%s][%s][%s]", cfg.Name, service, ver, typeKey) 208 | } 209 | 210 | if err := verifyHttpMethod(typeKey, &cfg.GetMethod); err != nil { 211 | t.Errorf("error with resource[%s][%s][%s].GetIamPolicy: %v", service, ver, typeKey, err) 212 | } 213 | if err := verifyHttpMethod(typeKey, &cfg.SetMethod); err != nil { 214 | t.Errorf("error with resource[%s][%s][%s].SetIamPolicy: %v", service, ver, typeKey, err) 215 | } 216 | } 217 | } 218 | } 219 | } 220 | 221 | func getFakeId(resourceType string) string { 222 | collectionIds := strings.Split(resourceType, "/") 223 | 224 | fakeId := "" 225 | for idx, cid := range collectionIds { 226 | suffix := letters[idx] 227 | fakeId += fmt.Sprintf("%s/aFakeId%s/", cid, string(suffix)) 228 | } 229 | return strings.Trim(fakeId, "/") 230 | } 231 | 232 | func verifyResource(rType string, resource Resource) (err error) { 233 | var req *http.Request 234 | if resource.GetRelativeId().TypeKey != rType { 235 | return fmt.Errorf("expected resource type %s, actual resource has different type %s", rType, resource.GetRelativeId().TypeKey) 236 | } 237 | 238 | req, err = constructRequest(resource, &resource.GetConfig().GetMethod, nil) 239 | if err != nil { 240 | return errwrap.Wrapf("unable to construct GetIamPolicyRequest: {{err}}", err) 241 | } 242 | if err := verifyConstructRequest(req, rType); err != nil { 243 | return err 244 | } 245 | 246 | req, err = constructRequest(resource, &resource.GetConfig().SetMethod, strings.NewReader("{}")) 247 | if err != nil { 248 | return errwrap.Wrapf("unable to construct SetIamPolicyRequest: {{err}}", err) 249 | } 250 | if err := verifyConstructRequest(req, rType); err != nil { 251 | return err 252 | } 253 | return nil 254 | } 255 | 256 | func verifyConstructRequest(req *http.Request, resourceType string) error { 257 | collectionIds := strings.Split(resourceType, "/") 258 | for idx := range collectionIds { 259 | suffix := letters[idx] 260 | rid := fmt.Sprintf("/aFakeId%s", string(suffix)) 261 | if !strings.Contains(req.URL.Path, rid) { 262 | return fmt.Errorf("expected expanded request URL %s to contain %s", req.URL.String(), rid) 263 | } 264 | } 265 | return nil 266 | } 267 | -------------------------------------------------------------------------------- /plugin/impersonated_account.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | 11 | "github.com/hashicorp/go-gcp-common/gcputil" 12 | "github.com/hashicorp/go-multierror" 13 | "github.com/hashicorp/go-secure-stdlib/strutil" 14 | "github.com/hashicorp/vault/sdk/framework" 15 | "github.com/hashicorp/vault/sdk/logical" 16 | ) 17 | 18 | func (b *backend) getImpersonatedAccount(name string, ctx context.Context, s logical.Storage) (*ImpersonatedAccount, error) { 19 | b.Logger().Debug("getting impersonated account from storage", "impersonated_account_name", name) 20 | entry, err := s.Get(ctx, fmt.Sprintf("%s/%s", impersonatedAccountStoragePrefix, name)) 21 | if err != nil { 22 | return nil, err 23 | } 24 | if entry == nil { 25 | return nil, nil 26 | } 27 | 28 | a := &ImpersonatedAccount{} 29 | if err := entry.DecodeJSON(a); err != nil { 30 | return nil, err 31 | } 32 | return a, nil 33 | } 34 | 35 | type ImpersonatedAccount struct { 36 | Name string 37 | gcputil.ServiceAccountId 38 | 39 | TokenScopes []string 40 | Ttl int 41 | } 42 | 43 | func (a *ImpersonatedAccount) validate() error { 44 | err := &multierror.Error{} 45 | if a.Name == "" { 46 | err = multierror.Append(err, errors.New("impersonated account name is empty")) 47 | } 48 | 49 | if a.EmailOrId == "" { 50 | err = multierror.Append(err, fmt.Errorf("impersonated account must have service account email")) 51 | } 52 | 53 | if len(a.TokenScopes) == 0 { 54 | err = multierror.Append(err, fmt.Errorf("access token impersonated account should have defined scopes")) 55 | } 56 | 57 | return err.ErrorOrNil() 58 | } 59 | 60 | // parseOkInputServiceAccountEmail checks that when creating a static account, a service account 61 | // email is provided. A service account email can be provided while updating the static account 62 | // but it must be the same as the one in the static account and cannot be updated. 63 | func (a *ImpersonatedAccount) parseOkInputServiceAccountEmail(d *framework.FieldData) (warnings []string, err error) { 64 | email := d.Get("service_account_email").(string) 65 | if email == "" && a.EmailOrId == "" { 66 | return nil, fmt.Errorf("email is required") 67 | } 68 | if a.EmailOrId != "" && email != "" && a.EmailOrId != email { 69 | return nil, fmt.Errorf("cannot update email") 70 | } 71 | 72 | a.EmailOrId = email 73 | return nil, nil 74 | } 75 | 76 | func (a *ImpersonatedAccount) parseOkInputTokenScopes(d *framework.FieldData) (warnings []string, err error) { 77 | v, ok := d.GetOk("token_scopes") 78 | if ok { 79 | scopes, castOk := v.([]string) 80 | if !castOk { 81 | return nil, fmt.Errorf("scopes unexpected type %T, expected []string", v) 82 | } 83 | a.TokenScopes = scopes 84 | } 85 | 86 | if len(a.TokenScopes) == 0 { 87 | return nil, fmt.Errorf("non-empty token_scopes must be provided for generating secrets") 88 | } 89 | 90 | return 91 | } 92 | 93 | func (a *ImpersonatedAccount) save(ctx context.Context, s logical.Storage) error { 94 | if err := a.validate(); err != nil { 95 | return err 96 | } 97 | 98 | entry, err := logical.StorageEntryJSON(fmt.Sprintf("%s/%s", impersonatedAccountStoragePrefix, a.Name), a) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | return s.Put(ctx, entry) 104 | } 105 | 106 | func (b *backend) tryDeleteImpersonatedAccountResources(ctx context.Context, req *logical.Request, boundResources *gcpAccountResources, walIds []string) []string { 107 | return b.tryDeleteGcpAccountResources(ctx, req, boundResources, flagMustKeepServiceAccount, walIds) 108 | } 109 | 110 | func (b *backend) createImpersonatedAccount(ctx context.Context, req *logical.Request, input *ImpersonatedAccount) (err error) { 111 | iamAdmin, err := b.IAMAdminClient(req.Storage) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | gcpAcct, err := b.getServiceAccount(iamAdmin, &gcputil.ServiceAccountId{ 117 | Project: gcpServiceAccountInferredProject, 118 | EmailOrId: input.EmailOrId, 119 | }) 120 | if err != nil { 121 | if isGoogleAccountNotFoundErr(err) { 122 | return fmt.Errorf("unable to create impersonated account, service account %q should exist", input.EmailOrId) 123 | } 124 | return fmt.Errorf("unable to create impersonated account, could not confirm service account %q exists: %w", input.EmailOrId, err) 125 | } 126 | 127 | acctId := gcputil.ServiceAccountId{ 128 | Project: gcpAcct.ProjectId, 129 | EmailOrId: gcpAcct.Email, 130 | } 131 | 132 | // Construct new impersonated account 133 | a := &ImpersonatedAccount{ 134 | Name: input.Name, 135 | ServiceAccountId: acctId, 136 | TokenScopes: input.TokenScopes, 137 | Ttl: input.Ttl, 138 | } 139 | 140 | // Save to storage. 141 | if err := a.save(ctx, req.Storage); err != nil { 142 | return err 143 | } 144 | 145 | return err 146 | } 147 | 148 | func (b *backend) updateImpersonatedAccount(ctx context.Context, req *logical.Request, a *ImpersonatedAccount, updateInput *ImpersonatedAccount) (warnings []string, err error) { 149 | iamAdmin, err := b.IAMAdminClient(req.Storage) 150 | if err != nil { 151 | return nil, err 152 | } 153 | 154 | _, err = b.getServiceAccount(iamAdmin, &a.ServiceAccountId) 155 | if err != nil { 156 | if isGoogleAccountNotFoundErr(err) { 157 | return nil, fmt.Errorf("unable to update impersonated account, could not find service account %q", a.ResourceName()) 158 | } 159 | return nil, fmt.Errorf("unable to create impersonated account, could not confirm service account %q exists: %w", a.ResourceName(), err) 160 | } 161 | 162 | madeChange := false 163 | if !strutil.EquivalentSlices(updateInput.TokenScopes, a.TokenScopes) { 164 | b.Logger().Debug("detected scopes change, updating scopes for impersonated account") 165 | a.TokenScopes = updateInput.TokenScopes 166 | madeChange = true 167 | } 168 | 169 | if updateInput.Ttl != a.Ttl { 170 | b.Logger().Debug("detected ttl change, updating ttl for impersonated account") 171 | a.Ttl = updateInput.Ttl 172 | madeChange = true 173 | } 174 | 175 | if !madeChange { 176 | return nil, nil 177 | } 178 | 179 | if err := a.save(ctx, req.Storage); err != nil { 180 | return nil, err 181 | } 182 | 183 | return 184 | } 185 | -------------------------------------------------------------------------------- /plugin/path_config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/hashicorp/go-gcp-common/gcputil" 13 | "github.com/hashicorp/vault/sdk/framework" 14 | "github.com/hashicorp/vault/sdk/helper/automatedrotationutil" 15 | "github.com/hashicorp/vault/sdk/helper/pluginidentityutil" 16 | "github.com/hashicorp/vault/sdk/helper/pluginutil" 17 | "github.com/hashicorp/vault/sdk/logical" 18 | "github.com/hashicorp/vault/sdk/rotation" 19 | ) 20 | 21 | func pathConfig(b *backend) *framework.Path { 22 | p := &framework.Path{ 23 | Pattern: "config", 24 | 25 | DisplayAttrs: &framework.DisplayAttributes{ 26 | OperationPrefix: operationPrefixGoogleCloud, 27 | }, 28 | 29 | Fields: map[string]*framework.FieldSchema{ 30 | "credentials": { 31 | Type: framework.TypeString, 32 | Description: `GCP IAM service account credentials JSON with permissions to create new service accounts and set IAM policies`, 33 | }, 34 | "ttl": { 35 | Type: framework.TypeDurationSecond, 36 | Description: "Default lease for generated keys. If <= 0, will use system default.", 37 | }, 38 | "max_ttl": { 39 | Type: framework.TypeDurationSecond, 40 | Description: "Maximum time a service account key is valid for. If <= 0, will use system default.", 41 | }, 42 | "service_account_email": { 43 | Type: framework.TypeString, 44 | Description: `Email ID for the Service Account to impersonate for Workload Identity Federation.`, 45 | }, 46 | }, 47 | 48 | Operations: map[logical.Operation]framework.OperationHandler{ 49 | logical.ReadOperation: &framework.PathOperation{ 50 | Callback: b.pathConfigRead, 51 | DisplayAttrs: &framework.DisplayAttributes{ 52 | OperationVerb: "read", 53 | OperationSuffix: "configuration", 54 | }, 55 | }, 56 | logical.UpdateOperation: &framework.PathOperation{ 57 | Callback: b.pathConfigWrite, 58 | DisplayAttrs: &framework.DisplayAttributes{ 59 | OperationVerb: "configure", 60 | }, 61 | ForwardPerformanceSecondary: true, 62 | ForwardPerformanceStandby: true, 63 | }, 64 | }, 65 | 66 | HelpSynopsis: pathConfigHelpSyn, 67 | HelpDescription: pathConfigHelpDesc, 68 | } 69 | 70 | pluginidentityutil.AddPluginIdentityTokenFields(p.Fields) 71 | automatedrotationutil.AddAutomatedRotationFields(p.Fields) 72 | 73 | return p 74 | } 75 | 76 | func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 77 | cfg, err := getConfig(ctx, req.Storage) 78 | if err != nil { 79 | return nil, err 80 | } 81 | if cfg == nil { 82 | return nil, nil 83 | } 84 | 85 | configData := map[string]interface{}{ 86 | "ttl": int64(cfg.TTL / time.Second), 87 | "max_ttl": int64(cfg.MaxTTL / time.Second), 88 | "service_account_email": cfg.ServiceAccountEmail, 89 | } 90 | 91 | cfg.PopulatePluginIdentityTokenData(configData) 92 | cfg.PopulateAutomatedRotationData(configData) 93 | 94 | return &logical.Response{ 95 | Data: configData, 96 | }, nil 97 | } 98 | 99 | func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 100 | // Check for existing config. 101 | cfg, err := getConfig(ctx, req.Storage) 102 | if err != nil { 103 | return nil, err 104 | } 105 | if cfg == nil { 106 | cfg = &config{} 107 | } 108 | 109 | credentialsRaw, setNewCreds := data.GetOk("credentials") 110 | if setNewCreds { 111 | _, err := gcputil.Credentials(credentialsRaw.(string)) 112 | if err != nil { 113 | return logical.ErrorResponse(fmt.Sprintf("invalid credentials JSON file: %v", err)), nil 114 | } 115 | cfg.CredentialsRaw = credentialsRaw.(string) 116 | } 117 | 118 | // set plugin identity token fields 119 | if err := cfg.ParsePluginIdentityTokenFields(data); err != nil { 120 | return logical.ErrorResponse(err.Error()), nil 121 | } 122 | 123 | // set automated root rotation fields 124 | if err := cfg.ParseAutomatedRotationFields(data); err != nil { 125 | return logical.ErrorResponse(err.Error()), nil 126 | } 127 | 128 | // set Service Account email 129 | saEmail, ok := data.GetOk("service_account_email") 130 | if ok { 131 | cfg.ServiceAccountEmail = saEmail.(string) 132 | } 133 | 134 | if cfg.IdentityTokenAudience != "" && cfg.CredentialsRaw != "" { 135 | return logical.ErrorResponse("only one of 'credentials' or 'identity_token_audience' can be set"), nil 136 | } 137 | 138 | if cfg.IdentityTokenAudience != "" && cfg.ServiceAccountEmail == "" { 139 | return logical.ErrorResponse("missing required 'service_account_email' when 'identity_token_audience' is set"), nil 140 | } 141 | 142 | // generate token to check if WIF is enabled on this edition of Vault 143 | if cfg.IdentityTokenAudience != "" { 144 | _, err := b.System().GenerateIdentityToken(ctx, &pluginutil.IdentityTokenRequest{ 145 | Audience: cfg.IdentityTokenAudience, 146 | }) 147 | if err != nil { 148 | if errors.Is(err, pluginidentityutil.ErrPluginWorkloadIdentityUnsupported) { 149 | return logical.ErrorResponse(err.Error()), nil 150 | } 151 | return nil, err 152 | } 153 | } 154 | 155 | // if token audience or TTL is being updated, ensure cached credentials are cleared 156 | _, audOk := data.GetOk("identity_token_audience") 157 | _, ttlOk := data.GetOk("identity_token_ttl") 158 | if audOk || ttlOk { 159 | setNewCreds = true 160 | } 161 | 162 | // Update token TTL. 163 | ttlRaw, ok := data.GetOk("ttl") 164 | if ok { 165 | cfg.TTL = time.Duration(ttlRaw.(int)) * time.Second 166 | } 167 | 168 | // Update token Max TTL. 169 | maxTTLRaw, ok := data.GetOk("max_ttl") 170 | if ok { 171 | cfg.MaxTTL = time.Duration(maxTTLRaw.(int)) * time.Second 172 | } 173 | 174 | var performedRotationManagerOpern string 175 | if cfg.ShouldDeregisterRotationJob() { 176 | performedRotationManagerOpern = "deregistration" 177 | // Disable Automated Rotation and Deregister credentials if required 178 | deregisterReq := &rotation.RotationJobDeregisterRequest{ 179 | MountPoint: req.MountPoint, 180 | ReqPath: req.Path, 181 | } 182 | 183 | b.Logger().Debug("Deregistering rotation job", "mount", req.MountPoint+req.Path) 184 | if err := b.System().DeregisterRotationJob(ctx, deregisterReq); err != nil { 185 | return logical.ErrorResponse("error deregistering rotation job: %s", err), nil 186 | } 187 | } else if cfg.ShouldRegisterRotationJob() { 188 | performedRotationManagerOpern = "registration" 189 | // Register the rotation job if it's required. 190 | cfgReq := &rotation.RotationJobConfigureRequest{ 191 | MountPoint: req.MountPoint, 192 | ReqPath: req.Path, 193 | RotationSchedule: cfg.RotationSchedule, 194 | RotationWindow: cfg.RotationWindow, 195 | RotationPeriod: cfg.RotationPeriod, 196 | } 197 | 198 | b.Logger().Debug("Registering rotation job", "mount", req.MountPoint+req.Path) 199 | if _, err = b.System().RegisterRotationJob(ctx, cfgReq); err != nil { 200 | return logical.ErrorResponse("error registering rotation job: %s", err), nil 201 | } 202 | } 203 | 204 | entry, err := logical.StorageEntryJSON("config", cfg) 205 | if err != nil { 206 | return nil, err 207 | } 208 | 209 | if err := req.Storage.Put(ctx, entry); err != nil { 210 | wrappedError := err 211 | if performedRotationManagerOpern != "" { 212 | b.Logger().Error("write to storage failed but the rotation manager still succeeded.", 213 | "operation", performedRotationManagerOpern, "mount", req.MountPoint, "path", req.Path) 214 | 215 | wrappedError = fmt.Errorf("write to storage failed but the rotation manager still succeeded; "+ 216 | "operation=%s, mount=%s, path=%s, storageError=%s", performedRotationManagerOpern, req.MountPoint, req.Path, err) 217 | } 218 | 219 | return nil, wrappedError 220 | } 221 | 222 | if setNewCreds { 223 | b.ClearCaches() 224 | } 225 | return nil, nil 226 | } 227 | 228 | type config struct { 229 | CredentialsRaw string 230 | 231 | TTL time.Duration 232 | MaxTTL time.Duration 233 | 234 | ServiceAccountEmail string 235 | pluginidentityutil.PluginIdentityTokenParams 236 | automatedrotationutil.AutomatedRotationParams 237 | } 238 | 239 | func getConfig(ctx context.Context, s logical.Storage) (*config, error) { 240 | var cfg config 241 | cfgRaw, err := s.Get(ctx, "config") 242 | if err != nil { 243 | return nil, err 244 | } 245 | if cfgRaw == nil { 246 | return nil, nil 247 | } 248 | 249 | if err := cfgRaw.DecodeJSON(&cfg); err != nil { 250 | return nil, err 251 | } 252 | 253 | return &cfg, err 254 | } 255 | 256 | const pathConfigHelpSyn = ` 257 | Configure the GCP backend. 258 | ` 259 | 260 | const pathConfigHelpDesc = ` 261 | The GCP backend requires credentials for managing IAM service accounts and keys 262 | and IAM policies on various GCP resources. This endpoint is used to configure 263 | those credentials as well as default values for the backend in general. 264 | ` 265 | -------------------------------------------------------------------------------- /plugin/path_config_ent_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/hashicorp/vault/sdk/helper/pluginutil" 13 | "github.com/hashicorp/vault/sdk/logical" 14 | "github.com/hashicorp/vault/sdk/rotation" 15 | ) 16 | 17 | // TestConfig_PluginIdentityToken_ent tests parsing and validation of 18 | // configuration used to set the secret engine up for web identity federation using 19 | // plugin identity tokens. 20 | func TestConfig_PluginIdentityToken_ent(t *testing.T) { 21 | config := logical.TestBackendConfig() 22 | config.StorageView = &logical.InmemStorage{} 23 | config.System = &testSystemViewEnt{} 24 | 25 | b := Backend() 26 | if err := b.Setup(context.Background(), config); err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | configData := map[string]interface{}{ 31 | "identity_token_ttl": int64(10), 32 | "identity_token_audience": "test-aud", 33 | "service_account_email": "test-service_account", 34 | } 35 | 36 | configReq := &logical.Request{ 37 | Operation: logical.UpdateOperation, 38 | Storage: config.StorageView, 39 | Path: "config", 40 | Data: configData, 41 | } 42 | 43 | resp, err := b.HandleRequest(context.Background(), configReq) 44 | if err != nil || (resp != nil && resp.IsError()) { 45 | t.Fatalf("bad: config writing failed: resp:%#v\n err: %v", resp, err) 46 | } 47 | 48 | resp, err = b.HandleRequest(context.Background(), &logical.Request{ 49 | Operation: logical.ReadOperation, 50 | Storage: config.StorageView, 51 | Path: "config", 52 | }) 53 | if err != nil || (resp != nil && resp.IsError()) { 54 | t.Fatalf("bad: config reading failed: resp:%#v\n err: %v", resp, err) 55 | } 56 | 57 | // Grab the subset of fields from the response we care to look at for this case 58 | got := map[string]interface{}{ 59 | "identity_token_ttl": resp.Data["identity_token_ttl"], 60 | "identity_token_audience": resp.Data["identity_token_audience"], 61 | "service_account_email": resp.Data["service_account_email"], 62 | } 63 | 64 | if !reflect.DeepEqual(got, configData) { 65 | t.Errorf("bad: expected to read config root as %#v, got %#v instead", configData, resp.Data) 66 | } 67 | 68 | credJson, err := getTestCredentials() 69 | if err != nil { 70 | t.Fatalf("error getting test credentials: %s", err) 71 | } 72 | // mutually exclusive fields must result in an error 73 | configData = map[string]interface{}{ 74 | "identity_token_audience": "test-aud", 75 | "credentials": credJson, 76 | } 77 | 78 | configReq = &logical.Request{ 79 | Operation: logical.UpdateOperation, 80 | Storage: config.StorageView, 81 | Path: "config", 82 | Data: configData, 83 | } 84 | 85 | resp, err = b.HandleRequest(context.Background(), configReq) 86 | if !resp.IsError() { 87 | t.Fatalf("expected an error but got nil") 88 | } 89 | expectedError := "only one of 'credentials' or 'identity_token_audience' can be set" 90 | if !strings.Contains(resp.Error().Error(), expectedError) { 91 | t.Fatalf("expected err %s, got %s", expectedError, resp.Error()) 92 | } 93 | 94 | // erase storage so that no service account email is in config 95 | config.StorageView = &logical.InmemStorage{} 96 | // missing email with audience must result in an error 97 | configData = map[string]interface{}{ 98 | "identity_token_audience": "test-aud", 99 | } 100 | 101 | configReq = &logical.Request{ 102 | Operation: logical.UpdateOperation, 103 | Storage: config.StorageView, 104 | Path: "config", 105 | Data: configData, 106 | } 107 | 108 | resp, err = b.HandleRequest(context.Background(), configReq) 109 | if !resp.IsError() { 110 | t.Fatalf("expected an error but got nil") 111 | } 112 | expectedError = "missing required 'service_account_email' when 'identity_token_audience' is set" 113 | if !strings.Contains(resp.Error().Error(), expectedError) { 114 | t.Fatalf("expected err %s, got %s", expectedError, resp.Error()) 115 | } 116 | } 117 | 118 | type testSystemViewEnt struct { 119 | logical.StaticSystemView 120 | } 121 | 122 | func (d testSystemViewEnt) GenerateIdentityToken(_ context.Context, _ *pluginutil.IdentityTokenRequest) (*pluginutil.IdentityTokenResponse, error) { 123 | return &pluginutil.IdentityTokenResponse{}, nil 124 | } 125 | 126 | func (d testSystemViewEnt) DeregisterRotationJob(_ context.Context, _ *rotation.RotationJobDeregisterRequest) error { 127 | return nil 128 | } 129 | -------------------------------------------------------------------------------- /plugin/path_config_rotate_root.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "encoding/base64" 9 | "fmt" 10 | 11 | "github.com/hashicorp/go-gcp-common/gcputil" 12 | "github.com/hashicorp/vault/sdk/framework" 13 | "github.com/hashicorp/vault/sdk/logical" 14 | "google.golang.org/api/iam/v1" 15 | ) 16 | 17 | func pathConfigRotateRoot(b *backend) *framework.Path { 18 | return &framework.Path{ 19 | Pattern: "config/rotate-root", 20 | 21 | DisplayAttrs: &framework.DisplayAttributes{ 22 | OperationPrefix: operationPrefixGoogleCloud, 23 | OperationVerb: "rotate", 24 | OperationSuffix: "root-credentials", 25 | }, 26 | 27 | Operations: map[logical.Operation]framework.OperationHandler{ 28 | logical.UpdateOperation: &framework.PathOperation{ 29 | Callback: b.pathConfigRotateRootWrite, 30 | ForwardPerformanceStandby: true, 31 | ForwardPerformanceSecondary: true, 32 | }, 33 | }, 34 | 35 | HelpSynopsis: pathConfigRotateRootHelpSyn, 36 | HelpDescription: pathConfigRotateRootHelpDesc, 37 | } 38 | } 39 | 40 | func (b *backend) pathConfigRotateRootWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { 41 | if err := b.rotateRootCredential(ctx, req); err != nil { 42 | return nil, err 43 | } 44 | 45 | cfg, err := getConfig(ctx, req.Storage) 46 | if err != nil { 47 | return nil, fmt.Errorf("rotated credentials but failed to reload config: %w", err) 48 | } 49 | 50 | // Parse the credential JSON to extract the private key ID to return in the response. 51 | creds, err := gcputil.Credentials(cfg.CredentialsRaw) 52 | if err != nil { 53 | return nil, fmt.Errorf("rotated credentials but failed to unmarshal: %w", err) 54 | } 55 | 56 | return &logical.Response{ 57 | Data: map[string]interface{}{ 58 | "private_key_id": creds.PrivateKeyId, 59 | }, 60 | }, nil 61 | } 62 | 63 | func (b *backend) rotateRootCredential(ctx context.Context, req *logical.Request) error { 64 | // Get the current configuration 65 | cfg, err := getConfig(ctx, req.Storage) 66 | if err != nil { 67 | return err 68 | } 69 | if cfg == nil { 70 | return fmt.Errorf("no configuration") 71 | } 72 | if cfg.CredentialsRaw == "" { 73 | return fmt.Errorf("configuration does not have credentials - this " + 74 | "endpoint only works with user-provided JSON credentials explicitly " + 75 | "provided via the config/ endpoint") 76 | } 77 | 78 | // Parse the credential JSON to extract the email (we need it for the API call) 79 | creds, err := gcputil.Credentials(cfg.CredentialsRaw) 80 | if err != nil { 81 | return fmt.Errorf("credentials are invalid: %w", err) 82 | } 83 | 84 | // Generate a new service account key 85 | iamAdmin, err := b.IAMAdminClient(req.Storage) 86 | if err != nil { 87 | return fmt.Errorf("failed to create iam client: %w", err) 88 | } 89 | 90 | saName := "projects/-/serviceAccounts/" + creds.ClientEmail 91 | newKey, err := iamAdmin.Projects.ServiceAccounts.Keys. 92 | Create(saName, &iam.CreateServiceAccountKeyRequest{ 93 | KeyAlgorithm: keyAlgorithmRSA2k, 94 | PrivateKeyType: privateKeyTypeJson, 95 | }). 96 | Context(ctx). 97 | Do() 98 | if err != nil { 99 | return fmt.Errorf("failed to create new key: %w", err) 100 | } 101 | 102 | // Base64-decode the private key data (it's the JSON file) 103 | newCredsJSON, err := base64.StdEncoding.DecodeString(newKey.PrivateKeyData) 104 | if err != nil { 105 | return fmt.Errorf("failed to decode credentials: %w", err) 106 | } 107 | 108 | // Verify creds are valid 109 | newCreds, err := gcputil.Credentials(string(newCredsJSON)) 110 | if err != nil { 111 | return fmt.Errorf("api returned invalid credentials: %w", err) 112 | } 113 | 114 | // Update the configuration 115 | cfg.CredentialsRaw = string(newCredsJSON) 116 | entry, err := logical.StorageEntryJSON("config", cfg) 117 | if err != nil { 118 | return fmt.Errorf("failed to generate new configuration: %w", err) 119 | } 120 | if err := req.Storage.Put(ctx, entry); err != nil { 121 | return fmt.Errorf("failed to save new configuration: %w", err) 122 | } 123 | 124 | // Clear caches to pick up the new credentials 125 | b.ClearCaches() 126 | 127 | // Delete the old service account key 128 | oldKeyName := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", 129 | creds.ProjectId, 130 | creds.ClientEmail, 131 | creds.PrivateKeyId) 132 | if _, err := iamAdmin.Projects.ServiceAccounts.Keys. 133 | Delete(oldKeyName). 134 | Context(ctx). 135 | Do(); err != nil { 136 | return fmt.Errorf("failed to delete old service account key (%q) - the new service "+ 137 | "account key (%q) is active, but the old one still exists: %w", 138 | creds.PrivateKeyId, newCreds.PrivateKeyId, err) 139 | } 140 | 141 | return nil 142 | } 143 | 144 | const pathConfigRotateRootHelpSyn = ` 145 | Request to rotate the GCP credentials used by Vault 146 | ` 147 | 148 | const pathConfigRotateRootHelpDesc = ` 149 | This path attempts to rotate the GCP service account credentials used by Vault 150 | for this mount. It does this by generating a new key for the service account, 151 | replacing the internal value, and then scheduling a deletion of the old service 152 | account key. Note that it does not create a new service account, only a new 153 | version of the service account key. 154 | 155 | This path is only valid if Vault has been configured to use GCP credentials via 156 | the config/ endpoint where "credentials" were specified. Additionally, the 157 | provided service account must have permissions to create and delete service 158 | account keys. 159 | ` 160 | -------------------------------------------------------------------------------- /plugin/path_config_rotate_root_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "encoding/base64" 9 | "fmt" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/hashicorp/go-gcp-common/gcputil" 15 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 16 | "github.com/hashicorp/vault/sdk/logical" 17 | "google.golang.org/api/iam/v1" 18 | "google.golang.org/api/option" 19 | ) 20 | 21 | func TestConfigRotateRootUpdate(t *testing.T) { 22 | t.Parallel() 23 | 24 | t.Run("no_configuration", func(t *testing.T) { 25 | t.Parallel() 26 | 27 | b, storage := getTestBackend(t) 28 | _, err := b.HandleRequest(context.Background(), &logical.Request{ 29 | Operation: logical.UpdateOperation, 30 | Path: "config/rotate-root", 31 | Storage: storage, 32 | }) 33 | if err == nil { 34 | t.Fatal("expected error") 35 | } 36 | if exp, act := "no configuration", err.Error(); !strings.Contains(act, exp) { 37 | t.Errorf("expected %q to contain %q", act, exp) 38 | } 39 | }) 40 | 41 | t.Run("config_with_no_credentials", func(t *testing.T) { 42 | t.Parallel() 43 | 44 | ctx := context.Background() 45 | b, storage := getTestBackend(t) 46 | 47 | entry, err := logical.StorageEntryJSON("config", &config{ 48 | TTL: 5 * time.Minute, 49 | }) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | if err := storage.Put(ctx, entry); err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | _, err = b.HandleRequest(ctx, &logical.Request{ 58 | Operation: logical.UpdateOperation, 59 | Path: "config/rotate-root", 60 | Storage: storage, 61 | }) 62 | if err == nil { 63 | t.Fatal("expected error") 64 | } 65 | if exp, act := "does not have credentials", err.Error(); !strings.Contains(act, exp) { 66 | t.Errorf("expected %q to contain %q", act, exp) 67 | } 68 | }) 69 | 70 | t.Run("config_with_invalid_credentials", func(t *testing.T) { 71 | t.Parallel() 72 | 73 | ctx := context.Background() 74 | b, storage := getTestBackend(t) 75 | 76 | entry, err := logical.StorageEntryJSON("config", &config{ 77 | CredentialsRaw: "baconbaconbacon", 78 | }) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | if err := storage.Put(ctx, entry); err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | _, err = b.HandleRequest(ctx, &logical.Request{ 87 | Operation: logical.UpdateOperation, 88 | Path: "config/rotate-root", 89 | Storage: storage, 90 | }) 91 | if err == nil { 92 | t.Fatal("expected error") 93 | } 94 | if exp, act := "credentials are invalid", err.Error(); !strings.Contains(act, exp) { 95 | t.Errorf("expected %q to contain %q", act, exp) 96 | } 97 | }) 98 | 99 | t.Run("rotate", func(t *testing.T) { 100 | t.Parallel() 101 | 102 | if testing.Short() { 103 | t.Skip("skipping integration test (short)") 104 | } 105 | 106 | ctx := context.Background() 107 | b, storage := getTestBackend(t) 108 | 109 | // Get user-supplied credentials 110 | _, creds := util.GetTestCredentials(t) 111 | client, err := gcputil.GetHttpClient(creds, iam.CloudPlatformScope) 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | // Create IAM client 117 | iamAdmin, err := iam.NewService(ctx, option.WithHTTPClient(client)) 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | // Create a new key, since this endpoint will revoke the key given. 123 | saName := "projects/-/serviceAccounts/" + creds.ClientEmail 124 | newKey, err := iamAdmin.Projects.ServiceAccounts.Keys. 125 | Create(saName, &iam.CreateServiceAccountKeyRequest{ 126 | KeyAlgorithm: keyAlgorithmRSA2k, 127 | PrivateKeyType: privateKeyTypeJson, 128 | }). 129 | Context(ctx). 130 | Do() 131 | if err != nil { 132 | t.Fatal(err) 133 | } 134 | 135 | // Base64-decode the private key data (it's the JSON file) 136 | newCredsJSON, err := base64.StdEncoding.DecodeString(newKey.PrivateKeyData) 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | 141 | // Parse new creds 142 | newCreds, err := gcputil.Credentials(string(newCredsJSON)) 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | 147 | // If we made it this far, schedule a cleanup of the key we just created. 148 | defer tryCleanupKey(t, iamAdmin, newKey.Name) 149 | 150 | // Set config to the key 151 | entry, err := logical.StorageEntryJSON("config", &config{ 152 | CredentialsRaw: string(newCredsJSON), 153 | }) 154 | if err != nil { 155 | t.Fatal(err) 156 | } 157 | if err := storage.Put(ctx, entry); err != nil { 158 | t.Fatal(err) 159 | } 160 | b.ClearCaches() 161 | 162 | // Rotate the key - retrying until success because of new key eventual consistency 163 | rawResp, err := retryTestFunc(func() (interface{}, error) { 164 | resp, err := b.HandleRequest(ctx, &logical.Request{ 165 | Operation: logical.UpdateOperation, 166 | Path: "config/rotate-root", 167 | Storage: storage, 168 | }) 169 | if err != nil { 170 | return resp, err 171 | } 172 | if resp != nil && resp.IsError() { 173 | return resp, resp.Error() 174 | } 175 | return resp, err 176 | }, maxTokenTestCalls) 177 | 178 | if err != nil { 179 | t.Fatal(err) 180 | } 181 | resp := rawResp.(*logical.Response) 182 | 183 | privateKeyId := resp.Data["private_key_id"] 184 | if privateKeyId == "" { 185 | t.Errorf("missing private_key_id") 186 | } 187 | 188 | // Make sure we delete the stored key, whether it was rotated or not (retry will not error) 189 | defer tryCleanupKey(t, iamAdmin, fmt.Sprintf(gcputil.ServiceAccountKeyTemplate, 190 | newCreds.ProjectId, 191 | newCreds.ClientEmail, 192 | privateKeyId)) 193 | 194 | if privateKeyId == newCreds.PrivateKeyId { 195 | t.Errorf("creds were not rotated") 196 | } 197 | }) 198 | } 199 | 200 | func tryCleanupKey(t *testing.T, iamAdmin *iam.Service, keyName string) { 201 | _, err := iamAdmin.Projects.ServiceAccounts.Keys.Delete(keyName).Do() 202 | if err != nil && !isGoogleAccountKeyNotFoundErr(err) { 203 | t.Logf("WARNING: failed to delete key created for test, clean up manually: %v", err) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /plugin/path_config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/vault/sdk/helper/jsonutil" 11 | "github.com/hashicorp/vault/sdk/helper/pluginidentityutil" 12 | "github.com/hashicorp/vault/sdk/helper/pluginutil" 13 | "github.com/hashicorp/vault/sdk/logical" 14 | "github.com/hashicorp/vault/sdk/rotation" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestConfig(t *testing.T) { 19 | t.Parallel() 20 | 21 | b, reqStorage := getTestBackend(t) 22 | 23 | testConfigRead(t, b, reqStorage, nil) 24 | 25 | credJson, err := getTestCredentials() 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | testConfigUpdate(t, b, reqStorage, map[string]interface{}{ 31 | "credentials": credJson, 32 | "service_account_email": "", 33 | "identity_token_audience": "", 34 | "identity_token_ttl": int64(0), 35 | }) 36 | 37 | expected := map[string]interface{}{ 38 | "ttl": int64(0), 39 | "max_ttl": int64(0), 40 | "service_account_email": "", 41 | "identity_token_audience": "", 42 | "identity_token_ttl": int64(0), 43 | "rotation_window": float64(0), 44 | "rotation_period": float64(0), 45 | "rotation_schedule": "", 46 | "disable_automated_rotation": false, 47 | } 48 | 49 | testConfigRead(t, b, reqStorage, expected) 50 | testConfigUpdate(t, b, reqStorage, map[string]interface{}{ 51 | "ttl": "50s", 52 | }) 53 | 54 | expected["ttl"] = int64(50) 55 | testConfigRead(t, b, reqStorage, expected) 56 | } 57 | 58 | // TestBackend_PathConfigRoot_PluginIdentityToken tests that configuration 59 | // of plugin WIF returns an immediate error. 60 | func TestConfig_PluginIdentityToken(t *testing.T) { 61 | config := logical.TestBackendConfig() 62 | config.StorageView = &logical.InmemStorage{} 63 | config.System = &testSystemView{} 64 | 65 | b := Backend() 66 | if err := b.Setup(context.Background(), config); err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | configData := map[string]interface{}{ 71 | "identity_token_ttl": int64(10), 72 | "identity_token_audience": "test-aud", 73 | "service_account_email": "test-service-account", 74 | } 75 | 76 | configReq := &logical.Request{ 77 | Operation: logical.UpdateOperation, 78 | Storage: config.StorageView, 79 | Path: "config", 80 | Data: configData, 81 | } 82 | 83 | resp, err := b.HandleRequest(context.Background(), configReq) 84 | assert.NoError(t, err) 85 | assert.NotNil(t, resp) 86 | assert.ErrorContains(t, resp.Error(), pluginidentityutil.ErrPluginWorkloadIdentityUnsupported.Error()) 87 | } 88 | 89 | func testConfigUpdate(t *testing.T, b logical.Backend, s logical.Storage, d map[string]interface{}) { 90 | resp, err := b.HandleRequest(context.Background(), &logical.Request{ 91 | Operation: logical.UpdateOperation, 92 | Path: "config", 93 | Data: d, 94 | Storage: s, 95 | }) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | if resp != nil && resp.IsError() { 100 | t.Fatal(resp.Error()) 101 | } 102 | } 103 | 104 | func testConfigRead(t *testing.T, b logical.Backend, s logical.Storage, expected map[string]interface{}) { 105 | resp, err := b.HandleRequest(context.Background(), &logical.Request{ 106 | Operation: logical.ReadOperation, 107 | Path: "config", 108 | Storage: s, 109 | }) 110 | if err != nil { 111 | t.Fatal(err) 112 | } 113 | 114 | if resp == nil && expected == nil { 115 | return 116 | } 117 | 118 | if resp.IsError() { 119 | t.Fatal(resp.Error()) 120 | } 121 | 122 | if len(expected) != len(resp.Data) { 123 | t.Errorf("read data mismatch (expected %d values, got %d)", len(expected), len(resp.Data)) 124 | } 125 | 126 | for k, expectedV := range expected { 127 | actualV, ok := resp.Data[k] 128 | 129 | if !ok { 130 | t.Errorf(`expected data["%s"] = %v but was not included in read output"`, k, expectedV) 131 | } else if expectedV != actualV { 132 | t.Errorf(`expected data["%s"] = %v, instead got %v"`, k, expectedV, actualV) 133 | } 134 | } 135 | 136 | if t.Failed() { 137 | t.FailNow() 138 | } 139 | } 140 | 141 | func getTestCredentials() ([]byte, error) { 142 | creds := map[string]interface{}{ 143 | "client_email": "testUser@google.com", 144 | "client_id": "user123", 145 | "private_key_id": "privateKey123", 146 | "private_key": "iAmAPrivateKey", 147 | "project_id": "project123", 148 | } 149 | 150 | credJson, err := jsonutil.EncodeJSON(creds) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | return credJson, nil 156 | } 157 | 158 | type testSystemView struct { 159 | logical.StaticSystemView 160 | } 161 | 162 | func (d testSystemView) GenerateIdentityToken(_ context.Context, _ *pluginutil.IdentityTokenRequest) (*pluginutil.IdentityTokenResponse, error) { 163 | return nil, pluginidentityutil.ErrPluginWorkloadIdentityUnsupported 164 | } 165 | 166 | func (d testSystemView) DeregisterRotationJob(_ context.Context, _ *rotation.RotationJobDeregisterRequest) error { 167 | return nil 168 | } 169 | -------------------------------------------------------------------------------- /plugin/path_impersonated_account.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | 11 | "github.com/hashicorp/vault/sdk/framework" 12 | "github.com/hashicorp/vault/sdk/logical" 13 | ) 14 | 15 | const ( 16 | impersonatedAccountStoragePrefix = "impersonated-account" 17 | impersonatedAccountPathPrefix = "impersonated-account" 18 | ) 19 | 20 | func pathImpersonatedAccount(b *backend) *framework.Path { 21 | return &framework.Path{ 22 | Pattern: fmt.Sprintf("%s/%s", impersonatedAccountPathPrefix, framework.GenericNameRegex("name")), 23 | DisplayAttrs: &framework.DisplayAttributes{ 24 | OperationPrefix: operationPrefixGoogleCloud, 25 | OperationSuffix: "impersonated-account", 26 | }, 27 | Fields: map[string]*framework.FieldSchema{ 28 | "name": { 29 | Type: framework.TypeString, 30 | Description: "Required. Name to refer to this impersonated account in Vault. Cannot be updated.", 31 | }, 32 | "service_account_email": { 33 | Type: framework.TypeString, 34 | Description: "Required. Email of the GCP service account to manage. Cannot be updated.", 35 | }, 36 | "token_scopes": { 37 | Type: framework.TypeCommaStringSlice, 38 | Description: "List of OAuth scopes to assign to access tokens generated under this account.", 39 | }, 40 | "ttl": { 41 | Type: framework.TypeDurationSecond, 42 | Description: "Lifetime of the token for the impersonated account.", 43 | }, 44 | }, 45 | ExistenceCheck: b.pathImpersonatedAccountExistenceCheck, 46 | Operations: map[logical.Operation]framework.OperationHandler{ 47 | logical.DeleteOperation: &framework.PathOperation{ 48 | Callback: b.pathImpersonatedAccountDelete, 49 | }, 50 | logical.ReadOperation: &framework.PathOperation{ 51 | Callback: b.pathImpersonatedAccountRead, 52 | }, 53 | logical.CreateOperation: &framework.PathOperation{ 54 | Callback: b.pathImpersonatedAccountCreate, 55 | }, 56 | logical.UpdateOperation: &framework.PathOperation{ 57 | Callback: b.pathImpersonatedAccountUpdate, 58 | }, 59 | }, 60 | HelpSynopsis: pathImpersonatedAccountHelpSyn, 61 | HelpDescription: pathImpersonatedAccountHelpDesc, 62 | } 63 | } 64 | 65 | func pathImpersonatedAccountList(b *backend) *framework.Path { 66 | // Paths for listing impersonated accounts 67 | return &framework.Path{ 68 | Pattern: fmt.Sprintf("%ss?/?", impersonatedAccountPathPrefix), 69 | DisplayAttrs: &framework.DisplayAttributes{ 70 | OperationPrefix: operationPrefixGoogleCloud, 71 | OperationVerb: "list", 72 | OperationSuffix: "impersonated-accounts|impersonated-accounts2", 73 | }, 74 | Operations: map[logical.Operation]framework.OperationHandler{ 75 | logical.ListOperation: &framework.PathOperation{ 76 | Callback: b.pathImpersonatedAccountList, 77 | }, 78 | }, 79 | HelpSynopsis: pathListImpersonatedAccountHelpSyn, 80 | HelpDescription: pathListImpersonatedAccountHelpDesc, 81 | } 82 | } 83 | 84 | func (b *backend) pathImpersonatedAccountExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) { 85 | nameRaw, ok := d.GetOk("name") 86 | if !ok { 87 | return false, errors.New("impersonated account name is required") 88 | } 89 | 90 | acct, err := b.getImpersonatedAccount(nameRaw.(string), ctx, req.Storage) 91 | if err != nil { 92 | return false, err 93 | } 94 | 95 | return acct != nil, nil 96 | } 97 | 98 | func (b *backend) pathImpersonatedAccountRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 99 | nameRaw, ok := d.GetOk("name") 100 | if !ok { 101 | return logical.ErrorResponse("name is required"), nil 102 | } 103 | 104 | acct, err := b.getImpersonatedAccount(nameRaw.(string), ctx, req.Storage) 105 | if err != nil { 106 | return nil, err 107 | } 108 | if acct == nil { 109 | return nil, nil 110 | } 111 | 112 | data := map[string]interface{}{ 113 | "service_account_project": acct.Project, 114 | "service_account_email": acct.EmailOrId, 115 | "token_scopes": acct.TokenScopes, 116 | "ttl": acct.Ttl, 117 | } 118 | 119 | return &logical.Response{ 120 | Data: data, 121 | }, nil 122 | } 123 | 124 | func (b *backend) pathImpersonatedAccountDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 125 | nameRaw, ok := d.GetOk("name") 126 | if !ok { 127 | return logical.ErrorResponse("name is required"), nil 128 | } 129 | name := nameRaw.(string) 130 | 131 | b.impersonatedAccountLock.Lock() 132 | defer b.impersonatedAccountLock.Unlock() 133 | 134 | // Delete impersonated account 135 | b.Logger().Debug("deleting impersonated account from storage", "name", name) 136 | if err := req.Storage.Delete(ctx, fmt.Sprintf("%s/%s", impersonatedAccountStoragePrefix, name)); err != nil { 137 | return nil, err 138 | } 139 | 140 | b.Logger().Debug("finished deleting impersonated account from storage", "name", name) 141 | return nil, nil 142 | } 143 | 144 | func (b *backend) pathImpersonatedAccountCreate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 145 | input, warnings, err := b.parseImpersonateInformation(ImpersonatedAccount{}, d) 146 | if err != nil { 147 | return logical.ErrorResponse(err.Error()), nil 148 | } 149 | if input == nil { 150 | return nil, fmt.Errorf("plugin error - parse returned unexpected nil input") 151 | } 152 | 153 | b.impersonatedAccountLock.Lock() 154 | defer b.impersonatedAccountLock.Unlock() 155 | 156 | // Create and save impersonated account with new resources. 157 | if err := b.createImpersonatedAccount(ctx, req, input); err != nil { 158 | return logical.ErrorResponse(err.Error()), nil 159 | } 160 | if len(warnings) > 0 { 161 | return &logical.Response{Warnings: warnings}, nil 162 | } 163 | return nil, nil 164 | } 165 | 166 | func (b *backend) pathImpersonatedAccountUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 167 | nameRaw, ok := d.GetOk("name") 168 | if !ok { 169 | return logical.ErrorResponse("name is required"), nil 170 | } 171 | name := nameRaw.(string) 172 | 173 | b.impersonatedAccountLock.Lock() 174 | defer b.impersonatedAccountLock.Unlock() 175 | 176 | acct, err := b.getImpersonatedAccount(name, ctx, req.Storage) 177 | if err != nil { 178 | return nil, err 179 | } 180 | if acct == nil { 181 | return nil, fmt.Errorf("unable to find impersonated account %s to update", name) 182 | } 183 | 184 | updateInput, warnings, err := b.parseImpersonateInformation(*acct, d) 185 | if err != nil { 186 | return logical.ErrorResponse(err.Error()), nil 187 | } 188 | if updateInput == nil { 189 | return nil, fmt.Errorf("plugin error - parse returned unexpected nil input") 190 | } 191 | 192 | updateWarns, err := b.updateImpersonatedAccount(ctx, req, acct, updateInput) 193 | if err != nil { 194 | return logical.ErrorResponse("unable to update: %s", err), nil 195 | } 196 | warnings = append(warnings, updateWarns...) 197 | if len(warnings) > 0 { 198 | return &logical.Response{Warnings: warnings}, nil 199 | } 200 | return nil, nil 201 | } 202 | 203 | func (b *backend) pathImpersonatedAccountList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { 204 | accounts, err := req.Storage.List(ctx, fmt.Sprintf("%s/", impersonatedAccountStoragePrefix)) 205 | if err != nil { 206 | return nil, err 207 | } 208 | return logical.ListResponse(accounts), nil 209 | } 210 | 211 | func (b *backend) parseImpersonateInformation(prevValues ImpersonatedAccount, d *framework.FieldData) (*ImpersonatedAccount, []string, error) { 212 | var warnings []string 213 | 214 | nameRaw, ok := d.GetOk("name") 215 | if !ok { 216 | return nil, nil, fmt.Errorf("name is required") 217 | } 218 | prevValues.Name = nameRaw.(string) 219 | 220 | ws, err := prevValues.parseOkInputServiceAccountEmail(d) 221 | if err != nil { 222 | return nil, nil, err 223 | } else if len(ws) > 0 { 224 | warnings = append(warnings, ws...) 225 | } 226 | 227 | ws, err = prevValues.parseOkInputTokenScopes(d) 228 | if err != nil { 229 | return nil, nil, err 230 | } else if len(ws) > 0 { 231 | warnings = append(warnings, ws...) 232 | } 233 | 234 | ttl, ok := d.GetOk("ttl") 235 | if ok { 236 | prevValues.Ttl = ttl.(int) 237 | } 238 | 239 | return &prevValues, warnings, nil 240 | } 241 | 242 | const pathImpersonatedAccountHelpSyn = `Register and manage a GCP service account to generate credentials under` 243 | const pathImpersonatedAccountHelpDesc = ` 244 | This path allows you to register an impersonated GCP service account that you want to generate secrets against. 245 | Secrets (i.e.access tokens) are generated under this account. The account must exist at creation of impersonated 246 | account creation.` 247 | 248 | const pathListImpersonatedAccountHelpSyn = `List created impersonated accounts.` 249 | const pathListImpersonatedAccountHelpDesc = `List created impersonated accounts.` 250 | -------------------------------------------------------------------------------- /plugin/path_impersonated_account_secrets.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/hashicorp/vault/sdk/framework" 12 | "github.com/hashicorp/vault/sdk/logical" 13 | "google.golang.org/api/impersonate" 14 | "google.golang.org/api/option" 15 | ) 16 | 17 | func pathImpersonatedAccountSecretAccessToken(b *backend) *framework.Path { 18 | return &framework.Path{ 19 | Pattern: fmt.Sprintf("%s/%s/token", impersonatedAccountPathPrefix, framework.GenericNameRegex("name")), 20 | DisplayAttrs: &framework.DisplayAttributes{ 21 | OperationPrefix: operationPrefixGoogleCloud, 22 | OperationVerb: "generate", 23 | }, 24 | Fields: map[string]*framework.FieldSchema{ 25 | "name": { 26 | Type: framework.TypeString, 27 | Description: "Required. Name of the impersonated account.", 28 | }, 29 | }, 30 | Operations: map[logical.Operation]framework.OperationHandler{ 31 | logical.ReadOperation: &framework.PathOperation{ 32 | Callback: b.pathImpersonatedAccountAccessToken, 33 | DisplayAttrs: &framework.DisplayAttributes{ 34 | OperationSuffix: "impersonated-account-access-token", 35 | }, 36 | }, 37 | logical.UpdateOperation: &framework.PathOperation{ 38 | Callback: b.pathImpersonatedAccountAccessToken, 39 | DisplayAttrs: &framework.DisplayAttributes{ 40 | OperationSuffix: "impersonated-account-access-token2", 41 | }, 42 | }, 43 | }, 44 | HelpSynopsis: pathTokenHelpSyn, 45 | HelpDescription: pathTokenHelpDesc, 46 | } 47 | } 48 | 49 | func (b *backend) pathImpersonatedAccountAccessToken(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 50 | acctName := d.Get("name").(string) 51 | 52 | acct, err := b.getImpersonatedAccount(acctName, ctx, req.Storage) 53 | if err != nil { 54 | return nil, err 55 | } 56 | if acct == nil { 57 | return logical.ErrorResponse("impersonated account %q does not exists", acctName), nil 58 | } 59 | 60 | creds, err := b.credentials(req.Storage) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | cfg, err := getConfig(ctx, req.Storage) 66 | if err != nil { 67 | return nil, err 68 | } 69 | if cfg == nil { 70 | cfg = &config{} 71 | } 72 | 73 | warnings := []string{} 74 | acctTtl := time.Duration(acct.Ttl) * time.Second 75 | if acctTtl > cfg.MaxTTL { 76 | warnings = append(warnings, fmt.Sprintf("using backend max ttl %q which is less than impersonated account ttl %q for token", 77 | cfg.MaxTTL.String(), 78 | acctTtl.String())) 79 | acctTtl = cfg.MaxTTL 80 | } else if acctTtl == 0 { 81 | warnings = append(warnings, fmt.Sprintf("using backend default ttl %q since impersonated account ttl not configured for token", 82 | cfg.TTL.String())) 83 | acctTtl = cfg.TTL 84 | } 85 | 86 | tokenSource, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ 87 | TargetPrincipal: acct.EmailOrId, 88 | Scopes: acct.TokenScopes, 89 | Lifetime: acctTtl, 90 | }, option.WithCredentials(creds)) 91 | if err != nil { 92 | return logical.ErrorResponse("unable to generate token source: %v", err), nil 93 | } 94 | token, err := tokenSource.Token() 95 | if err != nil { 96 | return logical.ErrorResponse("unable to generate token - make sure your service account and key are still valid: %v", err), nil 97 | } 98 | 99 | return &logical.Response{ 100 | Data: map[string]interface{}{ 101 | "token": token.AccessToken, 102 | "token_ttl": token.Expiry.UTC().Sub(time.Now().UTC()) / (time.Second), 103 | "expires_at_seconds": token.Expiry.Unix(), 104 | }, 105 | Warnings: warnings, 106 | }, nil 107 | } 108 | -------------------------------------------------------------------------------- /plugin/path_impersonated_account_secrets_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "testing" 10 | "time" 11 | 12 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 13 | "google.golang.org/api/iam/v1" 14 | "google.golang.org/api/oauth2/v2" 15 | "google.golang.org/api/option" 16 | ) 17 | 18 | func TestImpersonatedSecrets_GetDefaultAccessToken(t *testing.T) { 19 | roleName := "test-imp-token" 20 | td := setupTest(t, "0h", "12h") 21 | 22 | tests := map[string]struct { 23 | ttl_req time.Duration 24 | ttl_rcv time.Duration 25 | }{ 26 | "unset ttl should be 1 hour": { 27 | ttl_req: 0, 28 | ttl_rcv: 1 * time.Hour, 29 | }, 30 | "30 minutes requested and received": { 31 | ttl_req: 30 * time.Minute, 32 | ttl_rcv: 30 * time.Minute, 33 | }, 34 | "1 hour requested and received": { 35 | ttl_req: 1 * time.Hour, 36 | ttl_rcv: 1 * time.Hour, 37 | }, 38 | } 39 | 40 | for tn, tt := range tests { 41 | t.Run(tn, func(t *testing.T) { 42 | ttl := testGetImpersonatedAccessToken(t, td, roleName, tt.ttl_req.String()) 43 | if ttl.Round(1*time.Minute) != tt.ttl_rcv { 44 | t.Fatalf("expected access token to have a TTL of %v but got: %v", tt.ttl_rcv, ttl) 45 | } 46 | }) 47 | } 48 | } 49 | 50 | func TestImpersonatedSecrets_GetExtendedAccessToken(t *testing.T) { 51 | 52 | roleName := "test-imp-token" 53 | td := setupTestCredentials(t) 54 | skipIfCredentialLifetimesNotExtended(t, td, roleName) 55 | 56 | setupTestBackend(t, td, "2h", "4h") 57 | 58 | tests := map[string]struct { 59 | ttl_req time.Duration 60 | ttl_rcv time.Duration 61 | }{ 62 | "unset ttl should be 2 hours": { 63 | ttl_req: 0, 64 | ttl_rcv: 2 * time.Hour, 65 | }, 66 | "account ttl below backend ttl should be allowed": { 67 | ttl_req: 1 * time.Hour, 68 | ttl_rcv: 1 * time.Hour, 69 | }, 70 | "2 hours requested and received": { 71 | ttl_req: 2 * time.Hour, 72 | ttl_rcv: 2 * time.Hour, 73 | }, 74 | "4 hours requested and received": { 75 | ttl_req: 4 * time.Hour, 76 | ttl_rcv: 4 * time.Hour, 77 | }, 78 | "6 hours requested but clamped to backend TTL": { 79 | ttl_req: 6 * time.Hour, 80 | ttl_rcv: 4 * time.Hour, 81 | }, 82 | } 83 | 84 | for tn, tt := range tests { 85 | t.Run(tn, func(t *testing.T) { 86 | ttl := testGetImpersonatedAccessToken(t, td, roleName, tt.ttl_req.String()) 87 | if ttl.Round(1*time.Minute) != tt.ttl_rcv { 88 | t.Fatalf("expected access token to have a TTL of %v but got: %v", tt.ttl_rcv, ttl) 89 | } 90 | }) 91 | } 92 | 93 | } 94 | 95 | func skipIfCredentialLifetimesNotExtended(t *testing.T, td *testData, roleName string) { 96 | 97 | policyName := fmt.Sprintf("projects/%s/policies/iam.allowServiceAccountCredentialLifetimeExtension", td.Project) 98 | policy, err := td.OrgAdmin.Organizations.Policies.GetEffectivePolicy(policyName).Do() 99 | if policy == nil || err != nil { 100 | t.Skipf("credential lifetime extension policy not found %v", err) 101 | } 102 | 103 | allowed := false 104 | for _, rule := range policy.Spec.Rules { 105 | if rule.AllowAll { 106 | allowed = true 107 | } 108 | } 109 | if !allowed { 110 | t.Skipf("credential lifetime extension not allowed for %q", roleName) 111 | } 112 | 113 | } 114 | 115 | func testGetImpersonatedAccessToken(t *testing.T, td *testData, roleName string, ttl string) (tokenTtl time.Duration) { 116 | 117 | defer cleanupImpersonate(t, td, roleName, util.StringSet{}) 118 | 119 | sa := createServiceAccount(t, td, roleName) 120 | defer deleteServiceAccount(t, td, sa) 121 | 122 | testImpersonateCreate(t, td, roleName, 123 | map[string]interface{}{ 124 | "service_account_email": sa.Email, 125 | "token_scopes": []string{iam.CloudPlatformScope}, 126 | "ttl": ttl, 127 | }) 128 | 129 | token := testGetToken(t, fmt.Sprintf("%s/%s/token", impersonatedAccountPathPrefix, roleName), td) 130 | 131 | goauth, err := oauth2.NewService(context.Background(), option.WithHTTPClient(td.HttpClient)) 132 | if err != nil { 133 | t.Fatalf("error setting google oauth2 client %q", err) 134 | } 135 | 136 | info, err := goauth.Tokeninfo().AccessToken(token).Do() 137 | if err != nil { 138 | t.Fatalf("error getting token info %q", err) 139 | } 140 | 141 | if info.IssuedTo != sa.UniqueId { 142 | t.Fatalf("token email %q does not match service account email %q", info.Email, sa.Email) 143 | } 144 | 145 | // Cleanup 146 | testImpersonateDelete(t, td, roleName) 147 | 148 | return time.Duration(info.ExpiresIn) * time.Second 149 | } 150 | -------------------------------------------------------------------------------- /plugin/path_role_set_secrets.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/hashicorp/vault/sdk/framework" 11 | "github.com/hashicorp/vault/sdk/logical" 12 | ) 13 | 14 | func fieldSchemaRoleSetServiceAccountKey() map[string]*framework.FieldSchema { 15 | return map[string]*framework.FieldSchema{ 16 | "roleset": { 17 | Type: framework.TypeString, 18 | Description: "Required. Name of the role set.", 19 | }, 20 | "key_algorithm": { 21 | Type: framework.TypeString, 22 | Description: fmt.Sprintf(`Private key algorithm for service account key - defaults to %s"`, keyAlgorithmRSA2k), 23 | Default: keyAlgorithmRSA2k, 24 | Query: true, 25 | }, 26 | "key_type": { 27 | Type: framework.TypeString, 28 | Description: fmt.Sprintf(`Private key type for service account key - defaults to %s"`, privateKeyTypeJson), 29 | Default: privateKeyTypeJson, 30 | Query: true, 31 | }, 32 | "ttl": { 33 | Type: framework.TypeDurationSecond, 34 | Description: "Lifetime of the service account key", 35 | Query: true, 36 | }, 37 | } 38 | } 39 | 40 | func fieldSchemaRoleSetAccessToken() map[string]*framework.FieldSchema { 41 | return map[string]*framework.FieldSchema{ 42 | "roleset": { 43 | Type: framework.TypeString, 44 | Description: "Required. Name of the role set.", 45 | }, 46 | } 47 | 48 | } 49 | 50 | func pathRoleSetSecretServiceAccountKey(b *backend) *framework.Path { 51 | return &framework.Path{ 52 | Pattern: fmt.Sprintf("roleset/%s/key", framework.GenericNameRegex("roleset")), 53 | DisplayAttrs: &framework.DisplayAttributes{ 54 | OperationPrefix: operationPrefixGoogleCloud, 55 | OperationVerb: "generate", 56 | }, 57 | Fields: fieldSchemaRoleSetServiceAccountKey(), 58 | ExistenceCheck: b.pathRoleSetExistenceCheck("roleset"), 59 | Operations: map[logical.Operation]framework.OperationHandler{ 60 | logical.ReadOperation: &framework.PathOperation{ 61 | Callback: b.pathRoleSetSecretKey, 62 | DisplayAttrs: &framework.DisplayAttributes{ 63 | OperationSuffix: "roleset-key2", 64 | }, 65 | }, 66 | logical.UpdateOperation: &framework.PathOperation{ 67 | Callback: b.pathRoleSetSecretKey, 68 | DisplayAttrs: &framework.DisplayAttributes{ 69 | OperationSuffix: "roleset-key", 70 | }, 71 | }, 72 | }, 73 | HelpSynopsis: pathServiceAccountKeySyn, 74 | HelpDescription: pathServiceAccountKeyDesc, 75 | } 76 | } 77 | 78 | func deprecatedPathRoleSetSecretServiceAccountKey(b *backend) *framework.Path { 79 | return &framework.Path{ 80 | Pattern: fmt.Sprintf("key/%s", framework.GenericNameRegex("roleset")), 81 | DisplayAttrs: &framework.DisplayAttributes{ 82 | OperationPrefix: operationPrefixGoogleCloud, 83 | OperationVerb: "generate", 84 | }, 85 | Deprecated: true, 86 | Fields: fieldSchemaRoleSetServiceAccountKey(), 87 | ExistenceCheck: b.pathRoleSetExistenceCheck("roleset"), 88 | Operations: map[logical.Operation]framework.OperationHandler{ 89 | logical.ReadOperation: &framework.PathOperation{ 90 | Callback: b.pathRoleSetSecretKey, 91 | DisplayAttrs: &framework.DisplayAttributes{ 92 | OperationSuffix: "roleset-key4", 93 | }, 94 | }, 95 | logical.UpdateOperation: &framework.PathOperation{ 96 | Callback: b.pathRoleSetSecretKey, 97 | DisplayAttrs: &framework.DisplayAttributes{ 98 | OperationSuffix: "roleset-key3", 99 | }, 100 | }, 101 | }, 102 | HelpSynopsis: pathServiceAccountKeySyn, 103 | HelpDescription: pathServiceAccountKeyDesc, 104 | } 105 | } 106 | 107 | func pathRoleSetSecretAccessToken(b *backend) *framework.Path { 108 | return &framework.Path{ 109 | Pattern: fmt.Sprintf("roleset/%s/token", framework.GenericNameRegex("roleset")), 110 | DisplayAttrs: &framework.DisplayAttributes{ 111 | OperationPrefix: operationPrefixGoogleCloud, 112 | OperationVerb: "generate", 113 | }, 114 | Fields: fieldSchemaRoleSetAccessToken(), 115 | ExistenceCheck: b.pathRoleSetExistenceCheck("roleset"), 116 | Operations: map[logical.Operation]framework.OperationHandler{ 117 | logical.ReadOperation: &framework.PathOperation{ 118 | Callback: b.pathRoleSetSecretAccessToken, 119 | DisplayAttrs: &framework.DisplayAttributes{ 120 | OperationSuffix: "roleset-access-token2", 121 | }, 122 | }, 123 | logical.UpdateOperation: &framework.PathOperation{ 124 | Callback: b.pathRoleSetSecretAccessToken, 125 | DisplayAttrs: &framework.DisplayAttributes{ 126 | OperationSuffix: "roleset-access-token", 127 | }, 128 | }, 129 | }, 130 | HelpSynopsis: pathTokenHelpSyn, 131 | HelpDescription: pathTokenHelpDesc, 132 | } 133 | } 134 | 135 | func deprecatedPathRoleSetSecretAccessToken(b *backend) *framework.Path { 136 | return &framework.Path{ 137 | Pattern: fmt.Sprintf("token/%s", framework.GenericNameRegex("roleset")), 138 | DisplayAttrs: &framework.DisplayAttributes{ 139 | OperationPrefix: operationPrefixGoogleCloud, 140 | OperationVerb: "generate", 141 | }, 142 | Deprecated: true, 143 | Fields: fieldSchemaRoleSetAccessToken(), 144 | ExistenceCheck: b.pathRoleSetExistenceCheck("roleset"), 145 | Operations: map[logical.Operation]framework.OperationHandler{ 146 | logical.ReadOperation: &framework.PathOperation{ 147 | Callback: b.pathRoleSetSecretAccessToken, 148 | DisplayAttrs: &framework.DisplayAttributes{ 149 | OperationSuffix: "roleset-access-token4", 150 | }, 151 | }, 152 | logical.UpdateOperation: &framework.PathOperation{ 153 | Callback: b.pathRoleSetSecretAccessToken, 154 | DisplayAttrs: &framework.DisplayAttributes{ 155 | OperationSuffix: "roleset-access-token3", 156 | }, 157 | }, 158 | }, 159 | HelpSynopsis: pathTokenHelpSyn, 160 | HelpDescription: pathTokenHelpDesc, 161 | } 162 | } 163 | 164 | func (b *backend) pathRoleSetSecretKey(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 165 | rsName := d.Get("roleset").(string) 166 | keyType := d.Get("key_type").(string) 167 | keyAlg := d.Get("key_algorithm").(string) 168 | ttl := d.Get("ttl").(int) 169 | 170 | rs, err := getRoleSet(rsName, ctx, req.Storage) 171 | if err != nil { 172 | return nil, err 173 | } 174 | if rs == nil { 175 | return logical.ErrorResponse("role set %q does not exists", rsName), nil 176 | } 177 | 178 | if rs.SecretType != SecretTypeKey { 179 | return logical.ErrorResponse("role set %q cannot generate service account keys (has secret type %s)", rsName, rs.SecretType), nil 180 | } 181 | 182 | params := secretKeyParams{ 183 | keyType: keyType, 184 | keyAlgorithm: keyAlg, 185 | ttl: ttl, 186 | extraInternalData: map[string]interface{}{ 187 | "role_set": rs.Name, 188 | "role_set_bindings": rs.bindingHash(), 189 | }, 190 | } 191 | 192 | return b.createServiceAccountKeySecret(ctx, req.Storage, rs.AccountId, params) 193 | } 194 | 195 | func (b *backend) pathRoleSetSecretAccessToken(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 196 | rsName := d.Get("roleset").(string) 197 | 198 | rs, err := getRoleSet(rsName, ctx, req.Storage) 199 | if err != nil { 200 | return nil, err 201 | } 202 | if rs == nil { 203 | return logical.ErrorResponse("role set '%s' does not exists", rsName), nil 204 | } 205 | 206 | if rs.SecretType != SecretTypeAccessToken { 207 | return logical.ErrorResponse("role set '%s' cannot generate access tokens (has secret type %s)", rsName, rs.SecretType), nil 208 | } 209 | 210 | return b.secretAccessTokenResponse(ctx, req.Storage, rs.TokenGen) 211 | } 212 | -------------------------------------------------------------------------------- /plugin/path_static_account_rotate_key.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/hashicorp/vault/sdk/framework" 11 | "github.com/hashicorp/vault/sdk/logical" 12 | ) 13 | 14 | func pathStaticAccountRotateKey(b *backend) *framework.Path { 15 | return &framework.Path{ 16 | Pattern: fmt.Sprintf("%s/%s/rotate-key", staticAccountPathPrefix, framework.GenericNameRegex("name")), 17 | DisplayAttrs: &framework.DisplayAttributes{ 18 | OperationPrefix: operationPrefixGoogleCloud, 19 | OperationVerb: "rotate", 20 | OperationSuffix: "static-account-key", 21 | }, 22 | Fields: map[string]*framework.FieldSchema{ 23 | "name": { 24 | Type: framework.TypeString, 25 | Description: "Name of the account.", 26 | }, 27 | }, 28 | ExistenceCheck: b.pathStaticAccountExistenceCheck, 29 | Operations: map[logical.Operation]framework.OperationHandler{ 30 | logical.UpdateOperation: &framework.PathOperation{ 31 | Callback: b.pathStaticAccountRotateKey, 32 | ForwardPerformanceStandby: true, 33 | ForwardPerformanceSecondary: true, 34 | }, 35 | }, 36 | HelpSynopsis: pathStaticAccountRotateKeyHelpSyn, 37 | HelpDescription: pathStaticAccountRotateKeyHelpDesc, 38 | } 39 | } 40 | 41 | func (b *backend) pathStaticAccountRotateKey(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 42 | nameRaw, ok := d.GetOk("name") 43 | if !ok { 44 | return logical.ErrorResponse("name is required"), nil 45 | } 46 | name := nameRaw.(string) 47 | 48 | b.staticAccountLock.Lock() 49 | defer b.staticAccountLock.Unlock() 50 | 51 | acct, err := b.getStaticAccount(name, ctx, req.Storage) 52 | if err != nil { 53 | return nil, err 54 | } 55 | if acct == nil { 56 | return logical.ErrorResponse("account '%s' not found", name), nil 57 | } 58 | 59 | if acct.SecretType != SecretTypeAccessToken { 60 | return logical.ErrorResponse("cannot rotate key for non-access-token static account"), nil 61 | } 62 | 63 | if acct.TokenGen == nil { 64 | return nil, fmt.Errorf("unexpected invalid account has no TokenGen") 65 | } 66 | 67 | scopes := acct.TokenGen.Scopes 68 | oldTokenGen := acct.TokenGen 69 | oldWalId, err := b.addWalRoleSetServiceAccountKey(ctx, req, acct.Name, &acct.ServiceAccountId, oldTokenGen.KeyName) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | // Add WALs for new TokenGen - since we don't have a key ID yet, give an empty key name so WAL 75 | // will know to just clear keys that aren't being used. This also covers up cleaning up 76 | // the old token generator, so we don't add a separate WAL for that. 77 | newWalId, err := b.addWalRoleSetServiceAccountKey(ctx, req, acct.Name, &acct.ServiceAccountId, "") 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | newTokenGen, err := b.createNewTokenGen(ctx, req, acct.ResourceName(), scopes) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | // Edit roleset with new key and save to storage. 88 | acct.TokenGen = newTokenGen 89 | if err := acct.save(ctx, req.Storage); err != nil { 90 | return nil, err 91 | } 92 | 93 | // Try deleting the old key. 94 | iamAdmin, err := b.IAMAdminClient(req.Storage) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | b.tryDeleteWALs(ctx, req.Storage, newWalId) 100 | 101 | if oldTokenGen != nil { 102 | if err := b.deleteTokenGenKey(ctx, iamAdmin, oldTokenGen); err != nil { 103 | return &logical.Response{ 104 | Warnings: []string{ 105 | fmt.Sprintf("saved static account with new token generator service account key but failed to delete old key (covered by WAL): %v", err), 106 | }, 107 | }, nil 108 | } 109 | b.tryDeleteWALs(ctx, req.Storage, oldWalId) 110 | } 111 | return nil, nil 112 | } 113 | 114 | const pathStaticAccountRotateKeyHelpSyn = `Rotate the key used to generate access tokens for a static account` 115 | const pathStaticAccountRotateKeyHelpDesc = ` 116 | This path allows you to manually rotate the service account key 117 | created by Vault for a static account that generates access tokens secrets. 118 | This path only applies to static accounts that generate access tokens. 119 | It will not delete the associated service account or change bindings. 120 | 121 | Note that this will not invalidate access tokens created with the old key. 122 | The only way to do so is to delete the service account. 123 | ` 124 | -------------------------------------------------------------------------------- /plugin/path_static_account_rotate_key_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 13 | "github.com/hashicorp/vault/sdk/logical" 14 | "golang.org/x/oauth2" 15 | "google.golang.org/api/iam/v1" 16 | ) 17 | 18 | func TestStatic_Rotate(t *testing.T) { 19 | staticName := "test-static-rotate" 20 | secretType := SecretTypeAccessToken 21 | 22 | td := setupTest(t, "0s", "2h") 23 | defer cleanupStatic(t, td, staticName, testRoles) 24 | 25 | sa := createStaticAccount(t, td, staticName) 26 | defer deleteStaticAccount(t, td, sa) 27 | 28 | projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) 29 | 30 | expectedBinds := ResourceBindings{projRes: testRoles} 31 | bindsRaw, err := util.BindingsHCL(expectedBinds) 32 | if err != nil { 33 | t.Fatalf("unable to convert resource bindings to HCL string: %v", err) 34 | } 35 | testStaticCreate(t, td, staticName, 36 | map[string]interface{}{ 37 | "service_account_email": sa.Email, 38 | "token_scopes": []string{iam.CloudPlatformScope}, 39 | "secret_type": secretType, 40 | "bindings": bindsRaw, 41 | }) 42 | 43 | // expect error for trying to read key from token 44 | testGetKeyFail(t, td, fmt.Sprintf("%s/%s/key", staticAccountPathPrefix, staticName)) 45 | 46 | // Obtain current keys 47 | oldKeys := getServiceAccountKeys(t, td, sa.Name) 48 | 49 | // Get token and check 50 | token := testGetToken(t, fmt.Sprintf("%s/%s/token", staticAccountPathPrefix, staticName), td) 51 | callC := oauth2.NewClient( 52 | context.Background(), 53 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), 54 | ) 55 | checkSecretPermissions(t, td, callC) 56 | 57 | // Rotate key 58 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 59 | Operation: logical.UpdateOperation, 60 | Path: fmt.Sprintf("%s/%s/rotate-key", staticAccountPathPrefix, staticName), 61 | Data: map[string]interface{}{}, 62 | Storage: td.S, 63 | }) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | if resp != nil && resp.IsError() { 68 | t.Fatal(resp.Error()) 69 | } 70 | 71 | // Get new keys 72 | newKeys := getServiceAccountKeys(t, td, sa.Name) 73 | 74 | // Check that keys are actually rotated 75 | if reflect.DeepEqual(oldKeys, newKeys) { 76 | t.Fatal("expected keys to have been rotated, but they were not") 77 | } 78 | 79 | // Test token still works 80 | token = testGetToken(t, fmt.Sprintf("%s/%s/token", staticAccountPathPrefix, staticName), td) 81 | callC = oauth2.NewClient( 82 | context.Background(), 83 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), 84 | ) 85 | checkSecretPermissions(t, td, callC) 86 | 87 | // Cleanup 88 | testStaticDelete(t, td, staticName) 89 | verifyProjectBindingsRemoved(t, td, sa.Email, testRoles) 90 | } 91 | -------------------------------------------------------------------------------- /plugin/path_static_account_secrets.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/hashicorp/vault/sdk/framework" 11 | "github.com/hashicorp/vault/sdk/logical" 12 | ) 13 | 14 | func pathStaticAccountSecretServiceAccountKey(b *backend) *framework.Path { 15 | return &framework.Path{ 16 | Pattern: fmt.Sprintf("%s/%s/key", staticAccountPathPrefix, framework.GenericNameRegex("name")), 17 | DisplayAttrs: &framework.DisplayAttributes{ 18 | OperationPrefix: operationPrefixGoogleCloud, 19 | OperationVerb: "generate", 20 | }, 21 | Fields: map[string]*framework.FieldSchema{ 22 | "name": { 23 | Type: framework.TypeString, 24 | Description: "Required. Name of the static account.", 25 | }, 26 | "key_algorithm": { 27 | Type: framework.TypeString, 28 | Description: fmt.Sprintf(`Private key algorithm for service account key. Defaults to %s."`, keyAlgorithmRSA2k), 29 | Default: keyAlgorithmRSA2k, 30 | Query: true, 31 | }, 32 | "key_type": { 33 | Type: framework.TypeString, 34 | Description: fmt.Sprintf(`Private key type for service account key. Defaults to %s."`, privateKeyTypeJson), 35 | Default: privateKeyTypeJson, 36 | Query: true, 37 | }, 38 | "ttl": { 39 | Type: framework.TypeDurationSecond, 40 | Description: "Lifetime of the service account key", 41 | Query: true, 42 | }, 43 | }, 44 | Operations: map[logical.Operation]framework.OperationHandler{ 45 | logical.ReadOperation: &framework.PathOperation{ 46 | Callback: b.pathStaticAccountSecretKey, 47 | DisplayAttrs: &framework.DisplayAttributes{ 48 | OperationSuffix: "static-account-key2", 49 | }, 50 | }, 51 | logical.UpdateOperation: &framework.PathOperation{ 52 | Callback: b.pathStaticAccountSecretKey, 53 | DisplayAttrs: &framework.DisplayAttributes{ 54 | OperationSuffix: "static-account-key", 55 | }, 56 | }, 57 | }, 58 | HelpSynopsis: pathServiceAccountKeySyn, 59 | HelpDescription: pathServiceAccountKeyDesc, 60 | } 61 | } 62 | 63 | func pathStaticAccountSecretAccessToken(b *backend) *framework.Path { 64 | return &framework.Path{ 65 | Pattern: fmt.Sprintf("%s/%s/token", staticAccountPathPrefix, framework.GenericNameRegex("name")), 66 | DisplayAttrs: &framework.DisplayAttributes{ 67 | OperationPrefix: operationPrefixGoogleCloud, 68 | OperationVerb: "generate", 69 | }, 70 | Fields: map[string]*framework.FieldSchema{ 71 | "name": { 72 | Type: framework.TypeString, 73 | Description: "Required. Name of the static account.", 74 | }, 75 | }, 76 | Operations: map[logical.Operation]framework.OperationHandler{ 77 | logical.ReadOperation: &framework.PathOperation{ 78 | Callback: b.pathStaticAccountAccessToken, 79 | DisplayAttrs: &framework.DisplayAttributes{ 80 | OperationSuffix: "static-account-access-token2", 81 | }, 82 | }, 83 | logical.UpdateOperation: &framework.PathOperation{ 84 | Callback: b.pathStaticAccountAccessToken, 85 | DisplayAttrs: &framework.DisplayAttributes{ 86 | OperationSuffix: "static-account-access-token", 87 | }, 88 | }, 89 | }, 90 | HelpSynopsis: pathTokenHelpSyn, 91 | HelpDescription: pathTokenHelpDesc, 92 | } 93 | } 94 | 95 | func (b *backend) pathStaticAccountSecretKey(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 96 | acctName := d.Get("name").(string) 97 | keyType := d.Get("key_type").(string) 98 | keyAlg := d.Get("key_algorithm").(string) 99 | ttl := d.Get("ttl").(int) 100 | 101 | acct, err := b.getStaticAccount(acctName, ctx, req.Storage) 102 | if err != nil { 103 | return nil, err 104 | } 105 | if acct == nil { 106 | return logical.ErrorResponse("static account %q does not exists", acctName), nil 107 | } 108 | if acct.SecretType != SecretTypeKey { 109 | return logical.ErrorResponse("static account %q cannot generate service account keys (has secret type %s)", acctName, acct.SecretType), nil 110 | } 111 | 112 | params := secretKeyParams{ 113 | keyType: keyType, 114 | keyAlgorithm: keyAlg, 115 | ttl: ttl, 116 | extraInternalData: map[string]interface{}{ 117 | "static_account": acct.Name, 118 | "static_account_bindings": acct.bindingHash(), 119 | }, 120 | } 121 | 122 | return b.createServiceAccountKeySecret(ctx, req.Storage, &acct.ServiceAccountId, params) 123 | } 124 | 125 | func (b *backend) pathStaticAccountAccessToken(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 126 | acctName := d.Get("name").(string) 127 | 128 | acct, err := b.getStaticAccount(acctName, ctx, req.Storage) 129 | if err != nil { 130 | return nil, err 131 | } 132 | if acct == nil { 133 | return logical.ErrorResponse("static account %q does not exists", acctName), nil 134 | } 135 | if acct.SecretType != SecretTypeAccessToken { 136 | return logical.ErrorResponse("static account %q cannot generate access tokens (has secret type %s)", acctName, acct.SecretType), nil 137 | } 138 | 139 | return b.secretAccessTokenResponse(ctx, req.Storage, acct.TokenGen) 140 | } 141 | -------------------------------------------------------------------------------- /plugin/path_static_account_secrets_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "testing" 10 | 11 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 12 | "github.com/hashicorp/vault/sdk/logical" 13 | "golang.org/x/oauth2" 14 | "golang.org/x/oauth2/google" 15 | "google.golang.org/api/iam/v1" 16 | ) 17 | 18 | func TestStaticSecrets_GetAccessToken(t *testing.T) { 19 | staticName := "test-static-token" 20 | testGetStaticAccessToken(t, staticName) 21 | } 22 | 23 | func TestStaticSecrets_GetKey(t *testing.T) { 24 | staticName := "test-static-key" 25 | testGetStaticKey(t, staticName, 0) 26 | } 27 | 28 | func TestStaticSecrets_GetKeyTTLOverride(t *testing.T) { 29 | staticName := "test-static-key-ttl" 30 | testGetStaticKey(t, staticName, 1200) 31 | } 32 | 33 | func testGetStaticAccessToken(t *testing.T, staticName string) { 34 | secretType := SecretTypeAccessToken 35 | 36 | td := setupTest(t, "0s", "2h") 37 | defer cleanupStatic(t, td, staticName, testRoles) 38 | 39 | sa := createStaticAccount(t, td, staticName) 40 | defer deleteStaticAccount(t, td, sa) 41 | 42 | projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) 43 | 44 | expectedBinds := ResourceBindings{projRes: testRoles} 45 | bindsRaw, err := util.BindingsHCL(expectedBinds) 46 | if err != nil { 47 | t.Fatalf("unable to convert resource bindings to HCL string: %v", err) 48 | } 49 | testStaticCreate(t, td, staticName, 50 | map[string]interface{}{ 51 | "service_account_email": sa.Email, 52 | "token_scopes": []string{iam.CloudPlatformScope}, 53 | "secret_type": secretType, 54 | "bindings": bindsRaw, 55 | }) 56 | 57 | // expect error for trying to read key from token 58 | testGetKeyFail(t, td, fmt.Sprintf("%s/%s/key", staticAccountPathPrefix, staticName)) 59 | 60 | token := testGetToken(t, fmt.Sprintf("%s/%s/token", staticAccountPathPrefix, staticName), td) 61 | 62 | callC := oauth2.NewClient( 63 | context.Background(), 64 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), 65 | ) 66 | checkSecretPermissions(t, td, callC) 67 | 68 | // Cleanup 69 | testStaticDelete(t, td, staticName) 70 | verifyProjectBindingsRemoved(t, td, sa.Email, testRoles) 71 | } 72 | 73 | func testGetStaticKey(t *testing.T, staticName string, ttl uint64) { 74 | secretType := SecretTypeKey 75 | 76 | td := setupTest(t, "60s", "2h") 77 | defer cleanupStatic(t, td, staticName, testRoles) 78 | 79 | sa := createStaticAccount(t, td, staticName) 80 | defer deleteStaticAccount(t, td, sa) 81 | 82 | projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) 83 | 84 | expectedBinds := ResourceBindings{projRes: testRoles} 85 | bindsRaw, err := util.BindingsHCL(expectedBinds) 86 | if err != nil { 87 | t.Fatalf("unable to convert resource bindings to HCL string: %v", err) 88 | } 89 | testStaticCreate(t, td, staticName, 90 | map[string]interface{}{ 91 | "service_account_email": sa.Email, 92 | "secret_type": secretType, 93 | "bindings": bindsRaw, 94 | }) 95 | 96 | // expect error for trying to read token 97 | testGetTokenFail(t, td, fmt.Sprintf("%s/%s/token", staticAccountPathPrefix, staticName)) 98 | 99 | var creds *google.Credentials 100 | var resp *logical.Response 101 | if ttl == 0 { 102 | creds, resp = testGetKey(t, fmt.Sprintf("%s/%s/key", staticAccountPathPrefix, staticName), td) 103 | if uint(resp.Secret.LeaseTotal().Seconds()) != 60 { 104 | t.Fatalf("expected lease duration %d, got %d", 60, int(resp.Secret.LeaseTotal().Seconds())) 105 | } 106 | } else { 107 | // call the POST endpoint of /gcp/key/:roleset:/key with TTL 108 | creds, resp = testPostKey(t, td, fmt.Sprintf("%s/%s/key", staticAccountPathPrefix, staticName), fmt.Sprintf("%ds", ttl)) 109 | if uint64(resp.Secret.LeaseTotal().Seconds()) != ttl { 110 | t.Fatalf("expected lease duration %d, got %d", ttl, int(resp.Secret.LeaseTotal().Seconds())) 111 | } 112 | } 113 | 114 | if int(resp.Secret.LeaseOptions.MaxTTL.Hours()) != 2 { 115 | t.Fatalf("expected max lease %d, got %d", 2, int(resp.Secret.LeaseOptions.MaxTTL.Hours())) 116 | } 117 | 118 | secret := resp.Secret 119 | // Confirm calls with key work 120 | keyHttpC := oauth2.NewClient(context.Background(), creds.TokenSource) 121 | checkSecretPermissions(t, td, keyHttpC) 122 | 123 | keyName := secret.InternalData["key_name"].(string) 124 | if keyName == "" { 125 | t.Fatalf("expected internal data to include key name") 126 | } 127 | 128 | _, err = td.IamAdmin.Projects.ServiceAccounts.Keys.Get(keyName).Do() 129 | if err != nil { 130 | t.Fatalf("could not get key from given internal 'key_name': %v", err) 131 | } 132 | 133 | testRenewSecretKey(t, td, secret) 134 | testRevokeSecretKey(t, td, secret) 135 | 136 | verifyServiceAccountKeyDeleted(t, td.IamAdmin, keyName) 137 | 138 | // Cleanup 139 | testStaticDelete(t, td, staticName) 140 | verifyProjectBindingsRemoved(t, td, sa.Email, testRoles) 141 | } 142 | -------------------------------------------------------------------------------- /plugin/secrets_access_token.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "encoding/base64" 9 | "time" 10 | 11 | "github.com/hashicorp/errwrap" 12 | "github.com/hashicorp/vault/sdk/framework" 13 | "github.com/hashicorp/vault/sdk/logical" 14 | "golang.org/x/oauth2" 15 | "golang.org/x/oauth2/google" 16 | ) 17 | 18 | func (b *backend) secretAccessTokenResponse(ctx context.Context, s logical.Storage, tokenGen *TokenGenerator) (*logical.Response, error) { 19 | if tokenGen == nil || tokenGen.KeyName == "" { 20 | return logical.ErrorResponse("invalid token generator has no service account key"), nil 21 | } 22 | 23 | token, err := tokenGen.getAccessToken(ctx) 24 | if err != nil { 25 | return logical.ErrorResponse("unable to generate token - make sure your roleset service account and key are still valid: %v", err), nil 26 | } 27 | 28 | return &logical.Response{ 29 | Data: map[string]interface{}{ 30 | "token": token.AccessToken, 31 | "token_ttl": token.Expiry.UTC().Sub(time.Now().UTC()) / (time.Second), 32 | "expires_at_seconds": token.Expiry.Unix(), 33 | }, 34 | }, nil 35 | } 36 | 37 | func (tg *TokenGenerator) getAccessToken(ctx context.Context) (*oauth2.Token, error) { 38 | jsonBytes, err := base64.StdEncoding.DecodeString(tg.B64KeyJSON) 39 | if err != nil { 40 | return nil, errwrap.Wrapf("could not b64-decode key data: {{err}}", err) 41 | } 42 | 43 | cfg, err := google.JWTConfigFromJSON(jsonBytes, tg.Scopes...) 44 | if err != nil { 45 | return nil, errwrap.Wrapf("could not generate token JWT config: {{err}}", err) 46 | } 47 | 48 | tkn, err := cfg.TokenSource(ctx).Token() 49 | if err != nil { 50 | return nil, errwrap.Wrapf("got error while creating OAuth2 token: {{err}}", err) 51 | } 52 | return tkn, err 53 | } 54 | 55 | const deprecationWarning = ` 56 | This endpoint no longer generates leases due to limitations of the GCP API, as OAuth2 tokens belonging to Service 57 | Accounts cannot be revoked. This access_token and lease were created by a previous version of the GCP secrets 58 | engine and will be cleaned up now. Note that there is the chance that this access_token, if not already expired, 59 | will still be valid up to one hour. 60 | ` 61 | 62 | const pathTokenHelpSyn = `Generate an OAuth2 access token secret.` 63 | const pathTokenHelpDesc = ` 64 | This path will generate a new OAuth2 access token for accessing GCP APIs. 65 | 66 | Either specify "roleset/my-roleset" or "static/my-account" to generate a key corresponding 67 | to a roleset or static account respectively. 68 | 69 | Please see backend documentation for more information: 70 | https://www.vaultproject.io/docs/secrets/gcp/index.html 71 | ` 72 | 73 | // THIS SECRET TYPE IS DEPRECATED - future secret requests returns a response with no framework.Secret 74 | // We are keeping them as part of the created framework.Secret 75 | // to allow for clean up of access_token secrets and leases 76 | // from older versions of Vault. 77 | const SecretTypeAccessToken = "access_token" 78 | 79 | func secretAccessToken(b *backend) *framework.Secret { 80 | return &framework.Secret{ 81 | Type: SecretTypeAccessToken, 82 | Fields: map[string]*framework.FieldSchema{ 83 | "token": { 84 | Type: framework.TypeString, 85 | Description: "OAuth2 token", 86 | }, 87 | }, 88 | Renew: b.secretAccessTokenRenew, 89 | Revoke: b.secretAccessTokenRevoke, 90 | } 91 | } 92 | 93 | // Renewal will still return an error, but return the warning in case as well. 94 | func (b *backend) secretAccessTokenRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 95 | resp := logical.ErrorResponse("short-term access tokens cannot be renewed - request new access token instead") 96 | resp.AddWarning(deprecationWarning) 97 | return resp, nil 98 | } 99 | 100 | // Revoke will no-op and pass but warn the user. This is mostly to clean up old leases. 101 | // Any associated secret (access_token) has already expired and thus doesn't need to 102 | // actually be revoked, or will expire within an hour and currently can't actually be revoked anyways. 103 | func (b *backend) secretAccessTokenRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 104 | resp := &logical.Response{} 105 | resp.AddWarning(deprecationWarning) 106 | return resp, nil 107 | } 108 | -------------------------------------------------------------------------------- /plugin/secrets_service_account_key.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/hashicorp/errwrap" 12 | "github.com/hashicorp/go-gcp-common/gcputil" 13 | "github.com/hashicorp/vault/sdk/framework" 14 | "github.com/hashicorp/vault/sdk/logical" 15 | "google.golang.org/api/iam/v1" 16 | ) 17 | 18 | const ( 19 | SecretTypeKey = "service_account_key" 20 | keyAlgorithmRSA2k = "KEY_ALG_RSA_2048" 21 | privateKeyTypeJson = "TYPE_GOOGLE_CREDENTIALS_FILE" 22 | ) 23 | 24 | type secretKeyParams struct { 25 | keyType string 26 | keyAlgorithm string 27 | ttl int 28 | extraInternalData map[string]interface{} 29 | } 30 | 31 | func secretServiceAccountKey(b *backend) *framework.Secret { 32 | return &framework.Secret{ 33 | Type: SecretTypeKey, 34 | Fields: map[string]*framework.FieldSchema{ 35 | "private_key_data": { 36 | Type: framework.TypeString, 37 | Description: "Base-64 encoded string. Private key data for a service account key", 38 | }, 39 | "key_algorithm": { 40 | Type: framework.TypeString, 41 | Description: "Which type of key and algorithm to use for the key (defaults to 2K RSA). Valid values are GCP enum(ServiceAccountKeyAlgorithm)", 42 | }, 43 | "key_type": { 44 | Type: framework.TypeString, 45 | Description: "Type of the private key (i.e. whether it is JSON or P12). Valid values are GCP enum(ServiceAccountPrivateKeyType)", 46 | }, 47 | }, 48 | Renew: b.secretKeyRenew, 49 | Revoke: b.secretKeyRevoke, 50 | } 51 | } 52 | 53 | func (b *backend) secretKeyRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 54 | resp, err := b.verifySecretServiceKeyExists(ctx, req) 55 | if err != nil { 56 | return resp, err 57 | } 58 | if resp == nil { 59 | resp = &logical.Response{} 60 | } 61 | cfg, err := getConfig(ctx, req.Storage) 62 | if err != nil { 63 | return nil, err 64 | } 65 | if cfg == nil { 66 | cfg = &config{} 67 | } 68 | 69 | resp.Secret = req.Secret 70 | resp.Secret.TTL = cfg.TTL 71 | resp.Secret.MaxTTL = cfg.MaxTTL 72 | return resp, nil 73 | } 74 | 75 | func (b *backend) verifyBindingsNotUpdatedForSecret(ctx context.Context, req *logical.Request) error { 76 | if v, ok := req.Secret.InternalData["role_set"]; ok { 77 | bindingSum, ok := req.Secret.InternalData["role_set_bindings"] 78 | if !ok { 79 | return fmt.Errorf("invalid secret, internal data is missing role set bindings checksum") 80 | } 81 | 82 | // Verify role set was not deleted. 83 | rs, err := getRoleSet(v.(string), ctx, req.Storage) 84 | if err != nil { 85 | return fmt.Errorf("could not find role set %q to verify secret", v) 86 | } 87 | 88 | // Verify role set bindings have not changed since secret was generated. 89 | if rs.bindingHash() != bindingSum.(string) { 90 | return fmt.Errorf("role set '%v' bindings were updated since secret was generated, cannot renew", v) 91 | } 92 | } else if v, ok := req.Secret.InternalData["static_account"]; ok { 93 | bindingSum, ok := req.Secret.InternalData["static_account_bindings"] 94 | if !ok { 95 | return fmt.Errorf("invalid secret, internal data is missing static account bindings checksum") 96 | } 97 | 98 | // Verify static account was not deleted. 99 | sa, err := b.getStaticAccount(v.(string), ctx, req.Storage) 100 | if err != nil { 101 | return fmt.Errorf("could not find static account %q to verify secret", v) 102 | } 103 | 104 | // Verify static account bindings have not changed since secret was generated. 105 | if sa.bindingHash() != bindingSum.(string) { 106 | return fmt.Errorf("static account '%v' bindings were updated since secret was generated, cannot renew", v) 107 | } 108 | } else { 109 | return fmt.Errorf("invalid secret, internal data is missing role set or static account name") 110 | } 111 | 112 | return nil 113 | } 114 | 115 | func (b *backend) verifySecretServiceKeyExists(ctx context.Context, req *logical.Request) (*logical.Response, error) { 116 | keyName, ok := req.Secret.InternalData["key_name"] 117 | if !ok { 118 | return nil, fmt.Errorf("invalid secret, internal data is missing key name") 119 | } 120 | 121 | if err := b.verifyBindingsNotUpdatedForSecret(ctx, req); err != nil { 122 | return logical.ErrorResponse(err.Error()), err 123 | } 124 | 125 | // Verify service account key still exists. 126 | iamAdmin, err := b.IAMAdminClient(req.Storage) 127 | if err != nil { 128 | return logical.ErrorResponse("could not confirm key still exists in GCP"), nil 129 | } 130 | 131 | if k, err := iamAdmin.Projects.ServiceAccounts.Keys.Get(keyName.(string)).Do(); err != nil || k == nil { 132 | return logical.ErrorResponse("could not confirm key still exists in GCP: %v", err), nil 133 | } 134 | 135 | return nil, nil 136 | } 137 | 138 | func (b *backend) secretKeyRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 139 | keyNameRaw, ok := req.Secret.InternalData["key_name"] 140 | if !ok { 141 | return nil, fmt.Errorf("secret is missing key_name internal data") 142 | } 143 | 144 | iamAdmin, err := b.IAMAdminClient(req.Storage) 145 | if err != nil { 146 | return logical.ErrorResponse(err.Error()), nil 147 | } 148 | 149 | _, err = iamAdmin.Projects.ServiceAccounts.Keys.Delete(keyNameRaw.(string)).Context(ctx).Do() 150 | if err != nil && !isGoogleAccountKeyNotFoundErr(err) { 151 | return logical.ErrorResponse("unable to delete service account key: %v", err), nil 152 | } 153 | 154 | return nil, nil 155 | } 156 | 157 | func (b *backend) createServiceAccountKeySecret(ctx context.Context, s logical.Storage, id *gcputil.ServiceAccountId, params secretKeyParams) (*logical.Response, error) { 158 | cfg, err := getConfig(ctx, s) 159 | if err != nil { 160 | return nil, errwrap.Wrapf("could not read backend config: {{err}}", err) 161 | } 162 | if cfg == nil { 163 | cfg = &config{} 164 | } 165 | 166 | iamC, err := b.IAMAdminClient(s) 167 | if err != nil { 168 | return nil, errwrap.Wrapf("could not create IAM Admin client: {{err}}", err) 169 | } 170 | 171 | key, err := iamC.Projects.ServiceAccounts.Keys.Create( 172 | id.ResourceName(), &iam.CreateServiceAccountKeyRequest{ 173 | KeyAlgorithm: params.keyAlgorithm, 174 | PrivateKeyType: params.keyType, 175 | }).Do() 176 | if err != nil { 177 | return logical.ErrorResponse(err.Error()), nil 178 | } 179 | 180 | secretD := map[string]interface{}{ 181 | "private_key_data": key.PrivateKeyData, 182 | "key_algorithm": key.KeyAlgorithm, 183 | "key_type": key.PrivateKeyType, 184 | } 185 | internalD := map[string]interface{}{ 186 | "key_name": key.Name, 187 | } 188 | 189 | for k, v := range params.extraInternalData { 190 | internalD[k] = v 191 | } 192 | 193 | resp := b.Secret(SecretTypeKey).Response(secretD, internalD) 194 | resp.Secret.Renewable = true 195 | 196 | resp.Secret.MaxTTL = cfg.MaxTTL 197 | resp.Secret.TTL = cfg.TTL 198 | 199 | // If the request came with a TTL value, overwrite the config default 200 | if params.ttl > 0 { 201 | resp.Secret.TTL = time.Duration(params.ttl) * time.Second 202 | } 203 | 204 | return resp, nil 205 | } 206 | 207 | const pathServiceAccountKeySyn = `Generate a service account private key secret.` 208 | const pathServiceAccountKeyDesc = ` 209 | This path will generate a new service account key for accessing GCP APIs. 210 | 211 | Either specify "roleset/my-roleset" or "static/my-account" to generate a key corresponding 212 | to a roleset or static account respectively. 213 | 214 | Please see backend documentation for more information: 215 | https://www.vaultproject.io/docs/secrets/gcp/index.html 216 | ` 217 | -------------------------------------------------------------------------------- /plugin/secrets_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package gcpsecrets 5 | 6 | import ( 7 | "context" 8 | "encoding/base64" 9 | "fmt" 10 | "log" 11 | "net/http" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util" 17 | "github.com/hashicorp/vault/sdk/logical" 18 | "golang.org/x/oauth2/google" 19 | "google.golang.org/api/googleapi" 20 | "google.golang.org/api/iam/v1" 21 | "google.golang.org/api/option" 22 | ) 23 | 24 | const maxTokenTestCalls = 10 25 | 26 | var testRoles = util.StringSet{ 27 | // PERMISSIONS for roles/iam.roleViewer: 28 | // iam.roles.get 29 | // iam.roles.list 30 | // resourcemanager.projects.get 31 | // resourcemanager.projects.getIamPolicy 32 | "roles/iam.roleViewer": struct{}{}, 33 | } 34 | 35 | func getRoleSetAccount(t *testing.T, td *testData, rsName string) *iam.ServiceAccount { 36 | rs, err := getRoleSet(rsName, context.Background(), td.S) 37 | if err != nil { 38 | t.Fatalf("unable to get role set: %v", err) 39 | } 40 | if rs == nil || rs.AccountId == nil { 41 | t.Fatalf("role set not found") 42 | } 43 | 44 | sa, err := td.IamAdmin.Projects.ServiceAccounts.Get(rs.AccountId.ResourceName()).Do() 45 | if err != nil { 46 | t.Fatalf("unable to get service account: %v", err) 47 | } 48 | return sa 49 | } 50 | 51 | func testGetTokenFail(t *testing.T, td *testData, path string) { 52 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 53 | Operation: logical.UpdateOperation, 54 | Path: path, 55 | Data: make(map[string]interface{}), 56 | Storage: td.S, 57 | }) 58 | if err == nil && !resp.IsError() { 59 | t.Fatalf("expected error, instead got valid response (data: %v)", resp.Data) 60 | } 61 | 62 | error := resp.Error().Error() 63 | if !strings.Contains(error, "cannot generate access tokens (has secret type service_account_key)") { 64 | t.Fatalf("unexpected error: %s", error) 65 | } 66 | } 67 | 68 | func testGetKeyFail(t *testing.T, td *testData, path string) { 69 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 70 | Operation: logical.UpdateOperation, 71 | Path: path, 72 | Data: make(map[string]interface{}), 73 | Storage: td.S, 74 | }) 75 | if err == nil && !resp.IsError() { 76 | t.Fatalf("expected error, instead got valid response (data: %v)", resp.Data) 77 | } 78 | 79 | error := resp.Error().Error() 80 | if !strings.Contains(error, "cannot generate service account keys (has secret type access_token)") { 81 | t.Fatalf("unexpected error: %s", error) 82 | } 83 | } 84 | 85 | func retryGetToken(td *testData, path string) (*logical.Response, error) { 86 | // Newly created key in backend is eventually consistent. 87 | // Might take up to 60s according to Google's docs 88 | rawResp, err := retryTestFunc(func() (interface{}, error) { 89 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 90 | Operation: logical.ReadOperation, 91 | Path: path, 92 | Storage: td.S, 93 | }) 94 | 95 | if err != nil { 96 | return resp, err 97 | } 98 | 99 | if resp != nil && resp.IsError() { 100 | return resp, resp.Error() 101 | } 102 | return resp, err 103 | }, maxTokenTestCalls) 104 | 105 | resp := rawResp.(*logical.Response) 106 | return resp, err 107 | } 108 | 109 | func testGetToken(t *testing.T, path string, td *testData) (token string) { 110 | resp, err := retryGetToken(td, path) 111 | 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | if resp != nil && resp.IsError() { 116 | t.Fatal(resp.Error()) 117 | } 118 | 119 | if resp == nil || resp.Data == nil { 120 | t.Fatalf("expected response with secret, got response: %v", resp) 121 | } 122 | 123 | expiresAtRaw, ok := resp.Data["expires_at_seconds"] 124 | if !ok { 125 | t.Fatalf("expected 'expires_at' field to be returned") 126 | } 127 | expiresAt := time.Unix(expiresAtRaw.(int64), 0) 128 | if time.Now().Sub(expiresAt) > time.Hour { 129 | t.Fatalf("expected token to expire within an hour") 130 | } 131 | 132 | ttlRaw, ok := resp.Data["token_ttl"] 133 | if !ok { 134 | t.Fatalf("expected 'token_ttl' field to be returned") 135 | } 136 | tokenTtl := ttlRaw.(time.Duration) 137 | if tokenTtl > time.Hour || tokenTtl < 0 { 138 | t.Fatalf("expected token ttl to be less than one hour") 139 | } 140 | 141 | tokenRaw, ok := resp.Data["token"] 142 | if !ok { 143 | t.Fatalf("expected 'token' field to be returned") 144 | } 145 | return tokenRaw.(string) 146 | } 147 | 148 | // testPostKey enables the POST call to roleset|static/:name:/key 149 | func testPostKey(t *testing.T, td *testData, path, ttl string) (*google.Credentials, *logical.Response) { 150 | data := map[string]interface{}{} 151 | if ttl != "" { 152 | data["ttl"] = ttl 153 | } 154 | 155 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 156 | Operation: logical.UpdateOperation, 157 | Path: path, 158 | Storage: td.S, 159 | Data: data, 160 | }) 161 | 162 | if err != nil { 163 | t.Fatal(err) 164 | } 165 | if resp != nil && resp.IsError() { 166 | t.Fatal(resp.Error()) 167 | } 168 | if resp == nil || resp.Secret == nil { 169 | t.Fatalf("expected response with secret, got response: %v", resp) 170 | } 171 | 172 | creds := getGoogleCredentials(t, resp.Data) 173 | return creds, resp 174 | } 175 | 176 | func testGetKey(t *testing.T, path string, td *testData) (*google.Credentials, *logical.Response) { 177 | data := map[string]interface{}{} 178 | 179 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 180 | Operation: logical.ReadOperation, 181 | Path: path, 182 | Storage: td.S, 183 | Data: data, 184 | }) 185 | 186 | if err != nil { 187 | t.Fatal(err) 188 | } 189 | if resp != nil && resp.IsError() { 190 | t.Fatal(resp.Error()) 191 | } 192 | if resp == nil || resp.Secret == nil { 193 | t.Fatalf("expected response with secret, got response: %v", resp) 194 | } 195 | 196 | creds := getGoogleCredentials(t, resp.Data) 197 | return creds, resp 198 | } 199 | 200 | func testRenewSecretKey(t *testing.T, td *testData, sec *logical.Secret) { 201 | sec.IssueTime = time.Now() 202 | sec.Increment = time.Hour 203 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 204 | Operation: logical.RenewOperation, 205 | Secret: sec, 206 | Storage: td.S, 207 | }) 208 | 209 | if sec.Renewable { 210 | if err != nil { 211 | t.Fatalf("got error while trying to renew: %v", err) 212 | } else if resp.IsError() { 213 | t.Fatalf("got error while trying to renew: %v", resp.Error()) 214 | } 215 | } else if err == nil && !resp.IsError() { 216 | t.Fatal("expected error for attempting to renew non-renewable token") 217 | } 218 | } 219 | 220 | func testRevokeSecretKey(t *testing.T, td *testData, sec *logical.Secret) { 221 | resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ 222 | Operation: logical.RevokeOperation, 223 | Secret: sec, 224 | Storage: td.S, 225 | }) 226 | if err != nil { 227 | t.Fatal(err) 228 | } 229 | if resp != nil && resp.IsError() { 230 | t.Fatal(resp.Error()) 231 | } 232 | } 233 | 234 | func retryTestFunc(f func() (interface{}, error), retries int) (interface{}, error) { 235 | var err error 236 | var value interface{} 237 | for i := 0; i < retries; i++ { 238 | if value, err = f(); err == nil { 239 | return value, nil 240 | } 241 | log.Printf("[DEBUG] test check failed with error %v (attempt %d), sleeping one second before trying again", err, i) 242 | time.Sleep(time.Second) 243 | } 244 | return value, err 245 | } 246 | 247 | func checkSecretPermissions(t *testing.T, td *testData, httpC *http.Client) { 248 | iamAdmin, err := iam.NewService(context.Background(), option.WithHTTPClient(httpC)) 249 | if err != nil { 250 | t.Fatalf("could not construct new IAM Admin Service client from given token: %v", err) 251 | } 252 | 253 | // Should succeed: List roles 254 | _, err = retryTestFunc(func() (interface{}, error) { 255 | roles, err := iamAdmin.Projects.Roles.List(fmt.Sprintf("projects/%s", td.Project)).Do() 256 | return roles, err 257 | }, maxTokenTestCalls) 258 | if err != nil { 259 | t.Fatalf("expected call using authorized secret to succeed, instead got error: %v", err) 260 | } 261 | 262 | // Should fail (immediately): list service accounts 263 | _, err = iamAdmin.Projects.ServiceAccounts.List(fmt.Sprintf("projects/%s", td.Project)).Do() 264 | if err != nil { 265 | gErr, ok := err.(*googleapi.Error) 266 | if !ok { 267 | t.Fatalf("could not verify secret has permissions - got error %v", err) 268 | } 269 | if gErr.Code != 403 { 270 | t.Fatalf("expected call using unauthorized secret to be denied with 403, instead got error: %v", err) 271 | } 272 | } else { 273 | t.Fatalf("expected call using unauthorized secret to be denied with 403, instead succeeded") 274 | } 275 | } 276 | 277 | func getGoogleCredentials(t *testing.T, d map[string]interface{}) *google.Credentials { 278 | kAlg, ok := d["key_algorithm"] 279 | if !ok { 280 | t.Fatalf("expected 'key_algorithm' field to be returned") 281 | } 282 | if kAlg.(string) != keyAlgorithmRSA2k { 283 | t.Fatalf("expected 'key_algorithm' %s, got %v", keyAlgorithmRSA2k, kAlg) 284 | } 285 | 286 | kType, ok := d["key_type"] 287 | if !ok { 288 | t.Fatalf("expected 'key_type' field to be returned") 289 | } 290 | if kType.(string) != privateKeyTypeJson { 291 | t.Fatalf("expected 'key_type' %s, got %v", privateKeyTypeJson, kType) 292 | } 293 | 294 | keyDataRaw, ok := d["private_key_data"] 295 | if !ok { 296 | t.Fatalf("expected 'private_key_data' field to be returned") 297 | } 298 | keyJSON, err := base64.StdEncoding.DecodeString(keyDataRaw.(string)) 299 | if err != nil { 300 | t.Fatalf("could not b64 decode 'private_key_data' field: %v", err) 301 | } 302 | 303 | creds, err := google.CredentialsFromJSON(context.Background(), []byte(keyJSON), iam.CloudPlatformScope) 304 | if err != nil { 305 | t.Fatalf("could not get JWT config from given 'private_key_data': %v", err) 306 | } 307 | return creds 308 | } 309 | -------------------------------------------------------------------------------- /plugin/util/bindings_template: -------------------------------------------------------------------------------- 1 | {{define "bindings" -}} 2 | {{ range $resource,$roleStringSet := . -}} 3 | resource "{{$resource}}" { 4 | roles = [ 5 | {{- range $role, $v := $roleStringSet -}} 6 | "{{ $role }}", 7 | {{- end -}} 8 | ], 9 | } 10 | 11 | {{ end -}} 12 | {{- end }} -------------------------------------------------------------------------------- /plugin/util/parse_bindings.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package util 5 | 6 | import ( 7 | "bytes" 8 | "encoding/base64" 9 | "errors" 10 | "fmt" 11 | "io/ioutil" 12 | "strings" 13 | "text/template" 14 | 15 | "github.com/hashicorp/errwrap" 16 | "github.com/hashicorp/go-multierror" 17 | "github.com/hashicorp/hcl" 18 | "github.com/hashicorp/hcl/hcl/ast" 19 | ) 20 | 21 | const bindingTemplate = "util/bindings_template" 22 | 23 | func BindingsHCL(bindings map[string]StringSet) (string, error) { 24 | tpl, err := template.ParseFiles(bindingTemplate) 25 | if err != nil { 26 | return "", err 27 | } 28 | 29 | var buf bytes.Buffer 30 | if err := tpl.ExecuteTemplate(&buf, "bindings", bindings); err != nil { 31 | return "", err 32 | } 33 | return buf.String(), nil 34 | } 35 | 36 | func ParseBindings(bindingsStr string) (map[string]StringSet, error) { 37 | // Try to base64 decode 38 | decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(bindingsStr)) 39 | decoded, b64err := ioutil.ReadAll(decoder) 40 | 41 | var bindsString string 42 | if b64err != nil { 43 | bindsString = bindingsStr 44 | } else { 45 | bindsString = string(decoded) 46 | } 47 | 48 | root, err := hcl.Parse(bindsString) 49 | if err != nil { 50 | if b64err == nil { 51 | return nil, errwrap.Wrapf("unable to parse base64-encoded bindings as valid HCL: {{err}}", err) 52 | } else { 53 | return nil, errwrap.Wrapf("unable to parse raw string bindings as valid HCL: {{err}}", err) 54 | } 55 | } 56 | 57 | bindingLst, ok := root.Node.(*ast.ObjectList) 58 | if !ok { 59 | return nil, errors.New("unable to parse bindings: does not contain a root object") 60 | } 61 | 62 | bindingsMap, err := parseBindingObjList(bindingLst) 63 | if err != nil { 64 | return nil, errwrap.Wrapf("unable to parse bindings: {{err}}", err) 65 | } 66 | return bindingsMap, nil 67 | } 68 | 69 | func parseBindingObjList(topList *ast.ObjectList) (map[string]StringSet, error) { 70 | var merr *multierror.Error 71 | 72 | bindings := make(map[string]StringSet) 73 | 74 | for _, item := range topList.Items { 75 | err := parseResourceObject(item, bindings) 76 | if err != nil { 77 | merr = multierror.Append(merr, fmt.Errorf("(line %d) %v", item.Assign.Line, err)) 78 | } 79 | } 80 | err := merr.ErrorOrNil() 81 | if err != nil { 82 | return nil, err 83 | } 84 | return bindings, nil 85 | } 86 | 87 | func parseResourceObject(item *ast.ObjectItem, bindings map[string]StringSet) error { 88 | if len(item.Keys) != 2 || item.Keys[0] == nil || item.Keys[1] == nil { 89 | return fmt.Errorf(`top-level items must have format "resource" "$resource_name"`) 90 | } 91 | 92 | k, err := parseStringFromObjectKey(item, item.Keys[0]) 93 | if err != nil { 94 | return err 95 | } 96 | if k != "resource" { 97 | return fmt.Errorf(`invalid item %q, expected "resource"`, k) 98 | } 99 | 100 | resourceName, err := parseStringFromObjectKey(item, item.Keys[1]) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | _, ok := bindings[resourceName] 106 | if !ok { 107 | bindings[resourceName] = make(StringSet) 108 | } 109 | boundRoles := bindings[resourceName] 110 | 111 | resourceItemList := item.Val.(*ast.ObjectType).List 112 | if resourceItemList == nil { 113 | return fmt.Errorf("invalid empty roles list for item (line %d)", item.Assign.Line) 114 | } 115 | 116 | var merr *multierror.Error 117 | for _, rolesObj := range resourceItemList.Items { 118 | err := parseRolesObject(rolesObj, boundRoles) 119 | if err != nil { 120 | merr = multierror.Append(merr, fmt.Errorf("role list (line %d): %v", rolesObj.Assign.Line, err)) 121 | } 122 | } 123 | return merr.ErrorOrNil() 124 | } 125 | 126 | func parseRolesObject(rolesObj *ast.ObjectItem, parsedRoles StringSet) error { 127 | if rolesObj == nil || len(rolesObj.Keys) != 1 || rolesObj.Keys[0] == nil { 128 | return fmt.Errorf(`expected "roles" list, got nil object item`) 129 | } 130 | k, err := parseStringFromObjectKey(rolesObj, rolesObj.Keys[0]) 131 | if err != nil { 132 | return err 133 | } 134 | if k != "roles" { 135 | return fmt.Errorf(`invalid key %q in resource, expected "roles"`, k) 136 | } 137 | 138 | if rolesObj.Val == nil { 139 | return fmt.Errorf(`expected "roles" list, got nil value`) 140 | } 141 | roleList, ok := rolesObj.Val.(*ast.ListType) 142 | if !ok { 143 | return fmt.Errorf("parsing error, expected list of roles for key 'roles'") 144 | } 145 | var merr *multierror.Error 146 | for _, singleRoleObj := range roleList.List { 147 | role, err := parseRole(rolesObj, singleRoleObj) 148 | if err != nil { 149 | merr = multierror.Append(merr, err) 150 | } else { 151 | parsedRoles.Add(role) 152 | } 153 | } 154 | return merr.ErrorOrNil() 155 | } 156 | 157 | func parseRole(parent *ast.ObjectItem, roleNode ast.Node) (string, error) { 158 | if roleNode == nil { 159 | return "", fmt.Errorf(`unexpected empty role item (line %d)`, parent.Assign.Line) 160 | } 161 | 162 | roleLitType, ok := roleNode.(*ast.LiteralType) 163 | if !ok || roleLitType == nil { 164 | return "", fmt.Errorf(`unexpected nil item in roles list (line %d)`, parent.Assign.Line) 165 | } 166 | 167 | roleRaw := roleLitType.Token.Value() 168 | role, ok := roleRaw.(string) 169 | if !ok { 170 | return "", fmt.Errorf(`unexpected item %v in roles list is not a string (line %d)`, roleRaw, parent.Assign.Line) 171 | } 172 | 173 | tkns := strings.Split(role, "/") 174 | if len(tkns) == 2 && tkns[0] == "roles" { 175 | return role, nil 176 | } 177 | if len(tkns) == 4 && tkns[2] == "roles" { 178 | // "projects/X/roles/Y" or "organizations/X/roles/Y" 179 | if tkns[0] == "projects" || tkns[0] == "organizations" { 180 | return role, nil 181 | } 182 | } 183 | return "", fmt.Errorf(`invalid role %q (line %d) must be one of following formats: "projects/X/roles/Y", "organizations/X/roles/Y", "roles/X"`, role, parent.Assign.Line) 184 | } 185 | 186 | func parseStringFromObjectKey(parent *ast.ObjectItem, k *ast.ObjectKey) (string, error) { 187 | if k == nil || k.Token.Value() == nil { 188 | return "", fmt.Errorf("expected string, got nil value (Llne %d)", parent.Assign.Line) 189 | } 190 | vRaw := k.Token.Value() 191 | v, ok := vRaw.(string) 192 | if !ok { 193 | return "", fmt.Errorf("expected string, got %v (Llne %d)", parent.Assign.Line, vRaw) 194 | } 195 | 196 | return v, nil 197 | } 198 | -------------------------------------------------------------------------------- /plugin/util/parse_bindings_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package util 5 | 6 | import ( 7 | "encoding/base64" 8 | "testing" 9 | ) 10 | 11 | type testCase struct { 12 | Input string 13 | Expected map[string][]string 14 | } 15 | 16 | var testCases = []testCase{ 17 | { 18 | Input: ` 19 | resource "projects/X" { 20 | roles = [ 21 | "roles/viewer", 22 | ], 23 | }`, 24 | Expected: map[string][]string{ 25 | "projects/X": { 26 | "roles/viewer", 27 | }, 28 | }, 29 | }, 30 | 31 | { 32 | Input: ` 33 | resource "projects/X" { 34 | roles = [ 35 | "roles/role1", 36 | "projects/X/roles/customRole", 37 | ], 38 | } 39 | resource "//cloudresourcemanagers.com/projects/Y" { 40 | roles = [ 41 | "roles/compute.admin", 42 | "roles/anotherRole", 43 | ], 44 | }`, 45 | Expected: map[string][]string{ 46 | "projects/X": { 47 | "roles/role1", 48 | "projects/X/roles/customRole", 49 | }, 50 | "//cloudresourcemanagers.com/projects/Y": { 51 | "roles/compute.admin", 52 | "roles/anotherRole", 53 | }, 54 | }, 55 | }, 56 | } 57 | 58 | func TestParseBindings(t *testing.T) { 59 | checkParseBindings(t, false) 60 | } 61 | 62 | func TestParseBindingsB64(t *testing.T) { 63 | checkParseBindings(t, true) 64 | } 65 | 66 | func checkParseBindings(t *testing.T, encodeB64 bool) { 67 | for _, tc := range testCases { 68 | input := tc.Input 69 | if encodeB64 { 70 | input = base64.StdEncoding.EncodeToString([]byte(tc.Input)) 71 | } 72 | 73 | binds, err := ParseBindings(input) 74 | if err != nil { 75 | t.Errorf("unexpected error: %v \nInput: \n%s\n", err, tc.Input) 76 | } 77 | if len(tc.Expected) != len(binds) { 78 | t.Errorf("unexpected difference in number of bindings parsed; expected %d, got %d", len(tc.Expected), len(binds)) 79 | } 80 | for res, expected := range tc.Expected { 81 | actual, ok := binds[res] 82 | if !ok { 83 | t.Errorf("expected binding for resource '%s' not found", res) 84 | continue 85 | } 86 | 87 | if !actual.Equals(ToSet(expected)) { 88 | t.Errorf("expected bindings for resource '%s': %v; actual: %v", res, expected, actual.ToSlice()) 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /plugin/util/string_set.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package util 5 | 6 | // A set of strings 7 | type StringSet map[string]struct{} 8 | 9 | func ToSet(values []string) StringSet { 10 | s := make(StringSet) 11 | for _, v := range values { 12 | s[v] = struct{}{} 13 | } 14 | return s 15 | } 16 | 17 | func (ss StringSet) Add(v string) { 18 | ss[v] = struct{}{} 19 | } 20 | 21 | func (ss StringSet) ToSlice() []string { 22 | ls := make([]string, len(ss)) 23 | i := 0 24 | for r := range ss { 25 | ls[i] = r 26 | i++ 27 | } 28 | return ls 29 | } 30 | 31 | func (ss StringSet) Includes(v string) bool { 32 | _, ok := ss[v] 33 | return ok 34 | } 35 | 36 | func (ss StringSet) Update(members ...string) { 37 | for _, v := range members { 38 | ss[v] = struct{}{} 39 | } 40 | } 41 | 42 | func (ss StringSet) Union(other StringSet) StringSet { 43 | un := make(StringSet) 44 | for v := range ss { 45 | un[v] = struct{}{} 46 | } 47 | for v := range other { 48 | un[v] = struct{}{} 49 | } 50 | return un 51 | } 52 | 53 | func (ss StringSet) Intersection(other StringSet) StringSet { 54 | inter := make(StringSet) 55 | 56 | var s StringSet 57 | if len(ss) > len(other) { 58 | s = other 59 | } else { 60 | s = ss 61 | } 62 | 63 | for v := range s { 64 | if other.Includes(v) { 65 | inter[v] = struct{}{} 66 | } 67 | } 68 | return inter 69 | } 70 | 71 | func (ss StringSet) Sub(other StringSet) StringSet { 72 | sub := make(StringSet) 73 | for v := range ss { 74 | if !other.Includes(v) { 75 | sub[v] = struct{}{} 76 | } 77 | } 78 | return sub 79 | } 80 | 81 | func (ss StringSet) Equals(other StringSet) bool { 82 | return len(ss.Intersection(other)) == len(ss) 83 | } 84 | -------------------------------------------------------------------------------- /plugin/util/testing.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package util 5 | 6 | import ( 7 | "io/ioutil" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/hashicorp/go-gcp-common/gcputil" 13 | ) 14 | 15 | func GetTestCredentials(tb testing.TB) (string, *gcputil.GcpCredentials) { 16 | tb.Helper() 17 | 18 | if testing.Short() { 19 | tb.Skip("skipping integration test (short)") 20 | } 21 | 22 | var credsStr string 23 | credsEnv := os.Getenv("GOOGLE_TEST_CREDENTIALS") 24 | if credsEnv == "" { 25 | tb.Fatal("set GOOGLE_TEST_CREDENTIALS to JSON or path to JSON creds on disk to run integration tests") 26 | } 27 | 28 | // Attempt to read as file path; if invalid, assume given JSON value directly 29 | if _, err := os.Stat(credsEnv); err == nil { 30 | credsBytes, err := ioutil.ReadFile(credsEnv) 31 | if err != nil { 32 | tb.Fatalf("unable to read credentials file %s: %v", credsStr, err) 33 | } 34 | credsStr = string(credsBytes) 35 | } else { 36 | credsStr = credsEnv 37 | } 38 | 39 | creds, err := gcputil.Credentials(credsStr) 40 | if err != nil { 41 | tb.Fatalf("failed to parse GOOGLE_TEST_CREDENTIALS as JSON: %s", err) 42 | } 43 | return credsStr, creds 44 | } 45 | 46 | func GetTestProject(tb testing.TB) string { 47 | tb.Helper() 48 | 49 | if testing.Short() { 50 | tb.Skip("skipping integration test (short)") 51 | } 52 | 53 | project := strings.TrimSpace(os.Getenv("GOOGLE_CLOUD_PROJECT_ID")) 54 | if project == "" { 55 | tb.Fatal("set GOOGLE_CLOUD_PROJECT_ID to the ID of a GCP project to run integration tests") 56 | } 57 | return project 58 | } 59 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | TOOL=vault-plugin-secrets-gcp 7 | 8 | # 9 | # This script builds the application from source for a platform. 10 | set -e 11 | 12 | # Get the parent directory of where this script is. 13 | SOURCE="${BASH_SOURCE[0]}" 14 | while [ -h "$SOURCE" ] ; do SOURCE="$(readlink "$SOURCE")"; done 15 | DIR="$( cd -P "$( dirname "$SOURCE" )/.." && pwd )" 16 | 17 | # Change into that directory 18 | cd "$DIR" 19 | 20 | # Set build tags 21 | BUILD_TAGS="${BUILD_TAGS}:-${TOOL}" 22 | 23 | # Get the git commit 24 | GIT_COMMIT="$(git rev-parse HEAD)" 25 | GIT_DIRTY="$(test -n "`git status --porcelain`" && echo "+CHANGES" || true)" 26 | 27 | # Delete the old dir 28 | echo "==> Removing old directory..." 29 | rm -f bin/* 30 | mkdir -p bin/ 31 | 32 | 33 | # Build! 34 | echo "==> Building..." 35 | go build \ 36 | -ldflags "${LD_FLAGS} -X github.com/hashicorp/${TOOL}/version.GitCommit='${GIT_COMMIT}${GIT_DIRTY}'" \ 37 | -o "bin/${TOOL}" \ 38 | -tags="${BUILD_TAGS}" \ 39 | "cmd/${TOOL}/main.go" 40 | 41 | # Done! 42 | echo 43 | echo "==> Results:" 44 | ls -hl bin/ 45 | -------------------------------------------------------------------------------- /scripts/dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | set -eEuo pipefail 6 | 7 | MNT_PATH="gcp" 8 | PLUGIN_NAME="vault-plugin-secrets-gcp" 9 | 10 | # 11 | # Helper script for local development. Automatically builds and registers the 12 | # plugin. Requires `vault` is installed and available on $PATH. 13 | # 14 | 15 | # Get the right dir 16 | DIR="$(cd "$(dirname "$(readlink "$0")")" && pwd)" 17 | 18 | echo "==> Starting dev" 19 | 20 | echo "--> Scratch dir" 21 | echo " Creating" 22 | SCRATCH="${DIR}/tmp" 23 | mkdir -p "${SCRATCH}/plugins" 24 | 25 | function cleanup { 26 | echo "" 27 | echo "==> Cleaning up" 28 | kill -INT "${VAULT_PID}" 29 | rm -rf "${SCRATCH}" 30 | } 31 | trap cleanup EXIT 32 | 33 | echo "--> Building" 34 | go build "cmd/${PLUGIN_NAME}/main.go" -o "${SCRATCH}/plugins/${PLUGIN_NAME}" 35 | 36 | echo "--> Starting server" 37 | 38 | export VAULT_TOKEN="root" 39 | export VAULT_ADDR="http://127.0.0.1:8200" 40 | 41 | vault server \ 42 | -dev \ 43 | -dev-plugin-init \ 44 | -dev-plugin-dir "${SCRATCH}/plugins" \ 45 | -dev-root-token-id "root" \ 46 | -log-level "debug" \ 47 | & 48 | sleep 2 49 | VAULT_PID=$! 50 | 51 | echo " Mouting plugin" 52 | vault secrets enable -path=${MNT_PATH} -plugin-name=${PLUGIN_NAME} plugin 53 | 54 | echo "==> Ready!" 55 | wait ${VAULT_PID} 56 | -------------------------------------------------------------------------------- /scripts/gofmtcheck.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | echo "==> Checking that code complies with gofmt requirements..." 7 | 8 | gofmt_files=$(gofmt -l `find . -name '*.go' | grep -v vendor`) 9 | if [[ -n ${gofmt_files} ]]; then 10 | echo 'gofmt needs running on the following files:' 11 | echo "${gofmt_files}" 12 | echo "You can use the command: \`make fmt\` to reformat code." 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /scripts/gohelpers/create_custom_role.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "io/ioutil" 11 | "log" 12 | "os" 13 | "strings" 14 | 15 | "github.com/hashicorp/errwrap" 16 | "github.com/hashicorp/go-gcp-common/gcputil" 17 | "golang.org/x/oauth2" 18 | "google.golang.org/api/iam/v1" 19 | "google.golang.org/api/option" 20 | ) 21 | 22 | var defaultScopes = []string{ 23 | "https://www.googleapis.com/auth/cloud-platform", 24 | } 25 | 26 | var iamAdminPermissions = []string{ 27 | "iam.serviceAccounts.create", 28 | "iam.serviceAccounts.delete", 29 | "iam.serviceAccounts.get", 30 | "iam.serviceAccounts.list", 31 | "iam.serviceAccountKeys.create", 32 | "iam.serviceAccountKeys.delete", 33 | "iam.serviceAccountKeys.get", 34 | "iam.serviceAccountKeys.list", 35 | "iam.serviceAccounts.update", 36 | "iam.serviceAccounts.getIamPolicy", 37 | "iam.serviceAccounts.setIamPolicy", 38 | } 39 | 40 | func main() { 41 | var roleName, project, org, creds, stage string 42 | flag.StringVar(&roleName, "name", "vaultSecretsAdmin", "name of the custom IAM role to create") 43 | flag.StringVar(&project, "project", "", "Name of the GCP project to create custom IAM role under") 44 | flag.StringVar(&org, "organization", "", "Name of the GCP organization to create custom IAM role under") 45 | flag.StringVar(&creds, "credentials", "", "Either JSON contents for a GCP credentials JSON file or '@path/to/creds.json' (note '@' prepended)") 46 | flag.StringVar(&stage, "stage", "ALPHA", "Launch stage for role (ALPHA/BETA/GA)") 47 | flag.Parse() 48 | 49 | if err := validateFlags(roleName, project, org, stage); err != nil { 50 | log.Printf("unable to get client: %s\n", err) 51 | os.Exit(1) 52 | } 53 | 54 | iamAdmin, err := getIamClient(creds) 55 | if err != nil { 56 | log.Printf("unable to get client: %s\n", err) 57 | os.Exit(1) 58 | } 59 | 60 | var resource string 61 | if project != "" { 62 | resource = fmt.Sprintf("projects/%s", project) 63 | } else { 64 | resource = fmt.Sprintf("organizations/%s", org) 65 | } 66 | addPerms, err := getIamPermissions(iamAdmin, resource) 67 | if err != nil { 68 | log.Printf("unable to create role: %v", err) 69 | os.Exit(1) 70 | } 71 | 72 | req := &iam.CreateRoleRequest{ 73 | Role: &iam.Role{ 74 | Description: "Role that allow Vault GCP secrets engine to manage IAM service accounts and assign IAM policies", 75 | Stage: stage, 76 | IncludedPermissions: append(addPerms, iamAdminPermissions...), 77 | }, 78 | RoleId: roleName, 79 | } 80 | 81 | var r *iam.Role 82 | if project != "" { 83 | r, err = iamAdmin.Projects.Roles.Create(fmt.Sprintf("projects/%s", project), req).Do() 84 | } 85 | if org != "" { 86 | r, err = iamAdmin.Organizations.Roles.Create(fmt.Sprintf("organizations/%s", org), req).Do() 87 | } 88 | if err != nil { 89 | log.Printf("unable to create role: %v", err) 90 | os.Exit(1) 91 | } 92 | 93 | log.Printf("Success! Created role %s\n", r.Name) 94 | } 95 | 96 | func validateFlags(roleName, project, organization, stage string) error { 97 | if project == "" && organization == "" { 98 | return fmt.Errorf("exactly one of project or organization must be specified (role will be scoped to provided value)") 99 | } 100 | 101 | if project != "" && organization != "" { 102 | return fmt.Errorf("please specify only project or organization (role will be scoped to provided value)") 103 | } 104 | 105 | if roleName == "" { 106 | return fmt.Errorf("flag 'name' is required for name of role set") 107 | } 108 | 109 | switch stage { 110 | case "ALPHA", "BETA", "GA", "": 111 | break 112 | default: 113 | return fmt.Errorf("invalid launch stage: %s", stage) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func getIamClient(creds string) (*iam.Service, error) { 120 | if len(creds) > 1 && creds[0] == '@' { 121 | d, err := ioutil.ReadFile(creds[1:]) 122 | if err != nil { 123 | return nil, errwrap.Wrapf(fmt.Sprintf("unable to read contents of file '%s': {{err}}", creds[1:]), err) 124 | } 125 | creds = string(d) 126 | } 127 | 128 | _, tknSrc, err := gcputil.FindCredentials(creds, context.Background(), defaultScopes...) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | httpC := oauth2.NewClient(context.Background(), tknSrc) 134 | return iam.NewService(context.Background(), option.WithHTTPClient(httpC)) 135 | } 136 | 137 | func getIamPermissions(iamAdmin *iam.Service, resource string) ([]string, error) { 138 | fullName := fmt.Sprintf("//cloudresourcemanager.googleapis.com/%s", resource) 139 | 140 | nextToken, allPerms, err := getPermissions(iamAdmin, "", fullName) 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | for len(nextToken) > 0 { 146 | var perms []string 147 | nextToken, perms, err = getPermissions(iamAdmin, nextToken, fullName) 148 | if err != nil { 149 | return nil, err 150 | } 151 | allPerms = append(allPerms, perms...) 152 | } 153 | 154 | return allPerms, nil 155 | } 156 | 157 | func getPermissions(iamAdmin *iam.Service, nextPageToken, resource string) (string, []string, error) { 158 | req := &iam.QueryTestablePermissionsRequest{ 159 | FullResourceName: resource, 160 | PageToken: nextPageToken, 161 | } 162 | resp, err := iamAdmin.Permissions.QueryTestablePermissions(req).Do() 163 | if err != nil { 164 | return "", nil, err 165 | } 166 | 167 | permissions := make([]string, 0, len(resp.Permissions)) 168 | for _, perm := range resp.Permissions { 169 | if perm.ApiDisabled || perm.CustomRolesSupportLevel == "NOT_SUPPORTED" { 170 | continue 171 | } 172 | if strings.HasSuffix(perm.Name, ".getIamPolicy") || strings.HasSuffix(perm.Name, ".setIamPolicy") { 173 | permissions = append(permissions, perm.Name) 174 | } 175 | } 176 | return resp.NextPageToken, permissions, nil 177 | } 178 | -------------------------------------------------------------------------------- /scripts/update_deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | set -e 7 | 8 | TOOL=vault-plugin-secrets-gcp 9 | 10 | ## Make a temp dir 11 | tempdir=$(mktemp -d update-${TOOL}-deps.XXXXXX) 12 | 13 | ## Set paths 14 | export GOPATH="$(pwd)/${tempdir}" 15 | export PATH="${GOPATH}/bin:${PATH}" 16 | cd $tempdir 17 | 18 | ## Get tool 19 | mkdir -p src/github.com/hashicorp 20 | cd src/github.com/hashicorp 21 | echo "Fetching ${TOOL}..." 22 | git clone https://github.com/hashicorp/${TOOL}.git 23 | cd ${TOOL} 24 | 25 | ## Get golang dep tool 26 | go get -u github.com/golang/dep/cmd/dep 27 | 28 | ## Remove existing manifest 29 | rm -rf Gopkg* vendor 30 | 31 | ## Init 32 | dep init 33 | 34 | ## Fetch deps 35 | echo "Fetching deps, will take some time..." 36 | dep ensure 37 | echo "Pruning unused deps..." 38 | dep prune 39 | 40 | echo "Done; to commit run \n\ncd ${GOPATH}/src/github.com/hashicorp/${TOOL}\n" 41 | -------------------------------------------------------------------------------- /tests/acceptance/README.md: -------------------------------------------------------------------------------- 1 | # Acceptance Tests 2 | 3 | The following BATs tests can be used to test basic functionality of the GCP Secrets Engine. 4 | 5 | ## Prerequisites 6 | 7 | * Clone this repository to your workstation 8 | * [Bats Core installed](https://bats-core.readthedocs.io/en/stable/installation.html#homebrew) 9 | * Docker 10 | * Vault CLI installed 11 | * GCP Project that has a service account w/ [required permissions](https://www.vaultproject.io/docs/secrets/gcp#required-permissions) 12 | 13 | ### GCP Testing 14 | 15 | First, set the following env variables from your GCP project 16 | 17 | * SERVICE_ACCOUNT_ID 18 | * GOOGLE_APPLICATION_CREDENTIALS (path to service account credentials JSON file) 19 | * GOOGLE_CLOUD_PROJECT_ID 20 | * GOOGLE_CLOUD_PROJECT_NAME (used to write bindings file) 21 | * GOOGLE_REGION 22 | 23 | Run the tests: 24 | ```bash 25 | $ cd ./tests/acceptance 26 | $ bats gcp-secrets.bats 27 | ``` 28 | 29 | ### Output 30 | ``` 31 | ✓ Can successfully write GCP Secrets Config 32 | ✓ Can successfully write token roleset 33 | ✓ Can successfully generate oAuth tokens 34 | ✓ Can successfully write key roleset 35 | ✓ Can successfully generate dynamic keys 36 | ✓ Can successfully write access token static account 37 | ✓ Can successfully write service account key static account 38 | ``` 39 | 40 | -------------------------------------------------------------------------------- /tests/acceptance/configs/mybindings.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | resource "//cloudresourcemanager.googleapis.com/projects/vault-gcp-regression-test" { 5 | roles = ["roles/viewer"] 6 | } 7 | -------------------------------------------------------------------------------- /tests/acceptance/gcp-secrets.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | #load _helpers 4 | # 5 | #SKIP_TEARDOWN=true 6 | export VAULT_ADDR='http://127.0.0.1:8200' 7 | export VAULT_IMAGE="${VAULT_IMAGE:-hashicorp/vault:1.9.1}" 8 | 9 | if [[ -z $SERVICE_ACCOUNT_ID ]] 10 | then 11 | echo "SERVICE_ACCOUNT_ID env is not set. Exiting.." 12 | exit 1 13 | fi 14 | 15 | if [[ -z $GOOGLE_CLOUD_PROJECT_ID ]] 16 | then 17 | echo "GOOGLE_CLOUD_PROJECT_ID env is not set. Exiting.." 18 | exit 1 19 | fi 20 | 21 | if [[ -z $GOOGLE_CLOUD_PROJECT_NAME ]] 22 | then 23 | echo "GOOGLE_CLOUD_PROJECT_NAME env is not set. Exiting.." 24 | exit 1 25 | fi 26 | 27 | if [[ -z $GOOGLE_APPLICATION_CREDENTIALS ]] 28 | then 29 | echo "GOOGLE_APPLICATION_CREDENTIALS env is not set. Exiting.." 30 | exit 1 31 | fi 32 | 33 | export SETUP_TEARDOWN_OUTFILE=/tmp/output.log 34 | 35 | setup(){ 36 | { # Braces used to redirect all setup logs. 37 | # 1. Write bindings file. 38 | cat > tests/acceptance/configs/mybindings.hcl </dev/null 2>&1; do sleep 1; echo -n .; done; echo 64 | 65 | vault login ${VAULT_TOKEN?} 66 | 67 | vault secrets enable gcp 68 | } >> $SETUP_TEARDOWN_OUTFILE 69 | } 70 | 71 | teardown(){ 72 | if [[ -n $SKIP_TEARDOWN ]]; then 73 | echo "Skipping teardown" 74 | return 75 | fi 76 | 77 | { # Braces used to redirect all teardown logs. 78 | 79 | # Remove temp bindings file 80 | rm tests/acceptance/configs/mybindings.hcl 81 | 82 | # Remove credentials file 83 | rm ./creds.json 84 | 85 | vault secrets disable gcp 86 | # If the test failed, print some debug output 87 | if [[ "$BATS_ERROR_STATUS" -ne 0 ]]; then 88 | docker logs vault 89 | fi 90 | 91 | echo "${BATS_TEST_NAME}: [$BATS_ERROR_STATUS]: ${output}" >&2 92 | 93 | # Teardown Vault configuration. 94 | docker rm vault --force 95 | } >> $SETUP_TEARDOWN_OUTFILE 96 | } 97 | 98 | @test "Can successfully write GCP Secrets Config" { 99 | run vault write gcp/config \ 100 | credentials=@creds.json 101 | [ "${status?}" -eq 0 ] 102 | } 103 | 104 | @test "Can successfully write token roleset" { 105 | run vault write gcp/config \ 106 | credentials=@creds.json 107 | 108 | run vault write gcp/roleset/my-token-roleset \ 109 | project=${GOOGLE_CLOUD_PROJECT_ID?} \ 110 | secret_type="access_token" \ 111 | token_scopes="https://www.googleapis.com/auth/cloud-platform" \ 112 | bindings=@tests/acceptance/configs/mybindings.hcl 113 | [ "${status?}" -eq 0 ] 114 | } 115 | 116 | @test "Can successfully generate oAuth tokens" { 117 | run vault write gcp/config \ 118 | credentials=@creds.json 119 | 120 | run vault write gcp/roleset/my-token-roleset \ 121 | project=${GOOGLE_CLOUD_PROJECT_ID?} \ 122 | secret_type="access_token" \ 123 | token_scopes="https://www.googleapis.com/auth/cloud-platform" \ 124 | bindings=@tests/acceptance/configs/mybindings.hcl 125 | 126 | run vault read gcp/roleset/my-token-roleset/token 127 | [ "${status?}" -eq 0 ] 128 | } 129 | 130 | @test "Can successfully write key roleset" { 131 | run vault write gcp/config \ 132 | credentials=@creds.json 133 | 134 | run vault write gcp/roleset/my-key-roleset \ 135 | project=${GOOGLE_CLOUD_PROJECT_ID?} \ 136 | secret_type="service_account_key" \ 137 | token_scopes="https://www.googleapis.com/auth/cloud-platform" \ 138 | bindings=@tests/acceptance/configs/mybindings.hcl 139 | [ "${status?}" -eq 0 ] 140 | } 141 | 142 | @test "Can successfully generate dynamic keys" { 143 | run vault write gcp/config \ 144 | credentials=@creds.json 145 | 146 | run vault write gcp/roleset/my-key-roleset \ 147 | project=${GOOGLE_CLOUD_PROJECT_ID?} \ 148 | secret_type="service_account_key" \ 149 | token_scopes="https://www.googleapis.com/auth/cloud-platform" \ 150 | bindings=@tests/acceptance/configs/mybindings.hcl 151 | 152 | run vault read gcp/roleset/my-key-roleset/key 153 | [ "${status?}" -eq 0 ] 154 | } 155 | 156 | @test "Can successfully write access token static account" { 157 | run vault write gcp/config \ 158 | credentials=@creds.json 159 | 160 | run vault write gcp/static-account/my-token-account \ 161 | service_account_email=${SERVICE_ACCOUNT_ID?} \ 162 | secret_type="access_token" \ 163 | token_scopes="https://www.googleapis.com/auth/cloud-platform" \ 164 | bindings=@tests/acceptance/configs/mybindings.hcl 165 | [ "${status?}" -eq 0 ] 166 | } 167 | 168 | @test "Can successfully write service account key static account" { 169 | run vault write gcp/config \ 170 | credentials=@creds.json 171 | 172 | run vault write gcp/static-account/my-key-account \ 173 | service_account_email=${SERVICE_ACCOUNT_ID?} \ 174 | secret_type="service_account_key" \ 175 | token_scopes="https://www.googleapis.com/auth/cloud-platform" \ 176 | bindings=@tests/acceptance/configs/mybindings.hcl 177 | [ "${status?}" -eq 0 ] 178 | } 179 | --------------------------------------------------------------------------------