From a5d3626bfaa7a056f9256811151a937676aa20da Mon Sep 17 00:00:00 2001 From: Omid Andalib <24489388+oandalib@users.noreply.github.com> Date: Sat, 2 Nov 2024 13:51:07 -0700 Subject: [PATCH] feat: add Bitwarden Secrets Manager adapter --- lib/kamal/secrets/adapters.rb | 1 + .../adapters/bitwarden_secrets_manager.rb | 64 ++++++++++++ .../bitwarden_secrets_manager_adapter_test.rb | 99 +++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb create mode 100644 test/secrets/bitwarden_secrets_manager_adapter_test.rb diff --git a/lib/kamal/secrets/adapters.rb b/lib/kamal/secrets/adapters.rb index 439c7208a..19e6daed1 100644 --- a/lib/kamal/secrets/adapters.rb +++ b/lib/kamal/secrets/adapters.rb @@ -3,6 +3,7 @@ module Kamal::Secrets::Adapters def self.lookup(name) name = "one_password" if name.downcase == "1password" name = "last_pass" if name.downcase == "lastpass" + name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm" adapter_class(name) end diff --git a/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb b/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb new file mode 100644 index 000000000..893658ece --- /dev/null +++ b/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb @@ -0,0 +1,64 @@ +class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base + def requires_account? + false + end + + private + LIST_ALL_SELECTOR = "*" + LIST_ALL_FROM_PROJECT_SUFFIX = "/*" + LIST_COMMAND = "secret list -o env" + GET_COMMAND = "secret get -o env" + + def fetch_secrets(secrets, account:, session:) + if secrets.length == 1 + if secrets[0] == LIST_ALL_SELECTOR + command = LIST_COMMAND + elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX) + project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first + command = "#{LIST_COMMAND} #{project}" + end + end + + {}.tap do |results| + if command.nil? + secrets.each do |secret_uuid| + secret = run_command("#{GET_COMMAND} #{secret_uuid}") + raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success? + key, value = parse_secret(secret) + results[key] = value + end + else + secrets = run_command(command) + raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success? + secrets.split("\n").each do |secret| + key, value = parse_secret(secret) + results[key] = value + end + end + end + end + + def parse_secret(secret) + key, value = secret.split("=", 2) + value = value.gsub(/^"|"$/, "") + [ key, value ] + end + + def run_command(command, session: nil) + full_command = [ "bws", command ].join(" ") + `#{full_command}`.strip unless full_command.nil? + end + + def login(account) + nil + end + + def check_dependencies! + raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed? + end + + def cli_installed? + `bws --version 2> /dev/null` + $?.success? + end +end diff --git a/test/secrets/bitwarden_secrets_manager_adapter_test.rb b/test/secrets/bitwarden_secrets_manager_adapter_test.rb new file mode 100644 index 000000000..f19dead35 --- /dev/null +++ b/test/secrets/bitwarden_secrets_manager_adapter_test.rb @@ -0,0 +1,99 @@ +require "test_helper" + +class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase + test "fetch all" do + stub_ticks.with("bws --version 2> /dev/null") + stub_ticks + .with("bws secret list -o env") + .returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"\nMY_OTHER_SECRET=\"my=weird\"secret\"") + + expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}' + actual = shellunescape(run_command("fetch", "*")) + assert_equal expected, actual + end + + test "fetch all with from" do + stub_ticks.with("bws --version 2> /dev/null") + stub_ticks + .with("bws secret list -o env 82aeb5bd-6958-4a89-8197-eacab758acce") + .returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"\nMY_OTHER_SECRET=\"my=weird\"secret\"") + + expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}' + actual = shellunescape(run_command("fetch", "*", "--from", "82aeb5bd-6958-4a89-8197-eacab758acce")) + assert_equal expected, actual + end + + test "fetch item" do + stub_ticks.with("bws --version 2> /dev/null") + stub_ticks + .with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce") + .returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"") + + expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password"}' + actual = shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")) + assert_equal expected, actual + end + + test "fetch with multiple items" do + stub_ticks.with("bws --version 2> /dev/null") + stub_ticks + .with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce") + .returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"") + stub_ticks + .with("bws secret get -o env 6f8cdf27-de2b-4c77-a35d-07df8050e332") + .returns("MY_OTHER_SECRET=\"my=weird\"secret\"") + + expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}' + actual = shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce", "6f8cdf27-de2b-4c77-a35d-07df8050e332")) + assert_equal expected, actual + end + + test "fetch all empty" do + stub_ticks.with("bws --version 2> /dev/null") + stub_ticks_with("bws secret list -o env", succeed: false).returns("Error:\n0: Received error message from server") + + error = assert_raises RuntimeError do + (shellunescape(run_command("fetch", "*"))) + end + assert_equal("Could not read secrets from Bitwarden Secrets Manager", error.message) + end + + test "fetch nonexistent item" do + stub_ticks.with("bws --version 2> /dev/null") + stub_ticks_with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce", succeed: false) + .returns("ERROR (RuntimeError): Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager") + + error = assert_raises RuntimeError do + (shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce"))) + end + assert_equal("Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager", error.message) + end + + test "fetch with no session token" do + stub_ticks.with("bws --version 2> /dev/null") + stub_ticks_with("bws secret list -o env", succeed: false).returns("Error:\n0: Missing access token") + + error = assert_raises RuntimeError do + (shellunescape(run_command("fetch", "*"))) + end + assert_equal("Could not read secrets from Bitwarden Secrets Manager", error.message) + end + + test "fetch without CLI installed" do + stub_ticks_with("bws --version 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + shellunescape(run_command("fetch")) + end + assert_equal "Bitwarden Secrets Manager CLI is not installed", error.message + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "--adapter", "bitwarden-sm" ] + end + end +end