diff --git a/.circleci/config.yml b/.circleci/config.yml index 4f44e155..d3c6f532 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,8 +71,8 @@ workflows: only: /^v[0-9]+\.[0-9]+\.[0-9]+.*/ matrix: parameters: - ruby-version: ["2.7.1", "2.6", "2.5"] - vault-version: ["1.5.0", "1.4.2", "1.4.1", "1.4.0", "1.3.6"] + ruby-version: ["2.7.4", "2.7.1", "2.6", "2.5"] + vault-version: ["1.9.2", "1.5.0", "1.4.2", "1.4.1", "1.4.0", "1.3.6"] name: test-ruby-<< matrix.ruby-version >>-vault-<< matrix.vault-version >> - build-release: requires: diff --git a/lib/vault/api/kv.rb b/lib/vault/api/kv.rb index c400025c..4fb77440 100644 --- a/lib/vault/api/kv.rb +++ b/lib/vault/api/kv.rb @@ -122,6 +122,32 @@ def write_metadata(path, metadata = {}) true end + # Update the secret at the given path with the given data. Note that the + # data must be a {Hash}! Data will be merged with existing values. + # + # Note: This will raise an error if used on KV Secrets Engine Version 1. + # + # @example + # Vault.kv.write("secret/multiple", password: "secret") #=> # + # + # @param [String] path + # the path to update + # @param [Hash] data + # the data to merge + # + # @return [Secret] + def update(path, data = {}, options = {}) + headers = extract_headers!(options) + headers["Content-Type"] = "application/merge-patch+json" + json = client.patch("/v1/#{mount}/data/#{encode_path(path)}", JSON.fast_generate(:data => data), headers) + if json.nil? + return true + else + return Secret.decode(json) + end + end + + # Delete the secret at the given path. If the secret does not exist, vault # will still return true. # diff --git a/lib/vault/api/logical.rb b/lib/vault/api/logical.rb index 49754c52..9f422215 100644 --- a/lib/vault/api/logical.rb +++ b/lib/vault/api/logical.rb @@ -73,6 +73,32 @@ def write(path, data = {}, options = {}) end end + # Update the secret at the given path with the given data. Note that the + # data must be a {Hash}! Data will be merged with existing values. + # + # Note: This will raise an error if used on KV Secrets Engine Version 1. + # Note: The path must include `data` to work properly for Version 2. + # + # @example + # Vault.logical.update("secret/data/multiple", password: "secret") #=> # + # + # @param [String] path + # the path to write + # @param [Hash] data + # the data to write + # + # @return [Secret] + def update(path, data = {}, options = {}) + headers = extract_headers!(options) + headers["Content-Type"] = "application/merge-patch+json" + json = client.patch("/v1/#{encode_path(path)}", JSON.fast_generate(data), headers) + if json.nil? + return true + else + return Secret.decode(json) + end + end + # Delete the secret at the given path. If the secret does not exist, vault # will still return true. # diff --git a/spec/integration/api/kv_spec.rb b/spec/integration/api/kv_spec.rb index 7939801d..516cb0fb 100644 --- a/spec/integration/api/kv_spec.rb +++ b/spec/integration/api/kv_spec.rb @@ -60,7 +60,11 @@ module Vault subject.write("b:@c%n-read", foo: "bar") secret = subject.read("b:@c%n-read") expect(secret).to be - expect(secret.metadata.keys).to match_array([:created_time, :deletion_time, :version, :destroyed]) + if vault_meets_requirements?(">= 1.9.0") + expect(secret.metadata.keys).to match_array([:created_time, :deletion_time, :version, :destroyed, :custom_metadata]) + else + expect(secret.metadata.keys).to match_array([:created_time, :deletion_time, :version, :destroyed]) + end end end @@ -118,6 +122,38 @@ module Vault end end + describe "#update", vault: ">= 1.9.0" do + it "merges data and returns the secret" do + subject.write("test-update", zip: "zap") + subject.update("test-update", zig: "zag") + result = subject.read("test-update") + expect(result).to be + expect(result.data).to eq(zip: "zap", zig: "zag") + end + + it "raises an error if the path does not exist" do + expect { + subject.update("test-update-non-existent", zig: "zag") + }.to raise_error(Vault::HTTPClientError) + end + + it "raises an error if the path has been deleted" do + expect { + subject.write("test-update-deleted", zip: "zap") + subject.delete("test-update-deleted") + subject.update("test-update-deleted", zig: "zag") + }.to raise_error(Vault::HTTPClientError) + end + + it "raises an error if the path has been destroyed" do + expect { + subject.write("test-update-destroyed", zip: "zap") + subject.delete("test-update-destroyed") + subject.update("test-update-destroyed", zig: "zag") + }.to raise_error(Vault::HTTPClientError) + end + end + describe "#delete" do it "deletes the secret" do subject.write("delete", foo: "bar") diff --git a/spec/integration/api/logical_spec.rb b/spec/integration/api/logical_spec.rb index edb5bf15..82153037 100644 --- a/spec/integration/api/logical_spec.rb +++ b/spec/integration/api/logical_spec.rb @@ -91,6 +91,57 @@ module Vault end end + describe "#update", vault: ">= 1.9.0" do + context "v1 KV" do + before do + @original_mount = vault_test_client.sys.mounts[:secret] + vault_test_client.sys.unmount("secret") + vault_test_client.sys.mount( + "secret", "kv", "v1 KV", options: {version: "1"} + ) + end + + after do + vault_test_client.sys.unmount("secret") + vault_test_client.sys.mount( + "secret", @original_mount.type, @original_mount.description, options: @original_mount.options + ) + end + + it "raises an error" do + subject.write("secret/test-update-v1", zip: "zap") + expect { + subject.update("secret/test-update-v1", bacon: true) + }.to raise_error(Vault::HTTPClientError) + end + end + + context "v2 KV" do + before do + @original_mount = vault_test_client.sys.mounts[:secret] + vault_test_client.sys.unmount("secret") + vault_test_client.sys.mount( + "secret", "kv", "v2 KV", options: {version: "2"} + ) + end + + after do + vault_test_client.sys.unmount("secret") + vault_test_client.sys.mount( + "secret", @original_mount.type, @original_mount.description, options: @original_mount.options + ) + end + + it "updates existing secrets" do + subject.write("secret/data/test-update-v2", data: {zip: "zap"}) + subject.update("secret/data/test-update-v2", data: {bacon: true}) + result = subject.read("secret/data/test-update-v2") + expect(result).to be + expect(result.data[:data]).to eq(zip: "zap", bacon: true) + end + end + end + describe "#delete" do it "deletes the secret" do subject.write("secret/delete", foo: "bar")