diff --git a/lib/kamal/secrets/adapters/passbolt.rb b/lib/kamal/secrets/adapters/passbolt.rb new file mode 100644 index 00000000..9b9e2204 --- /dev/null +++ b/lib/kamal/secrets/adapters/passbolt.rb @@ -0,0 +1,130 @@ +class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base + def requires_account? + false + end + + private + + def login(*) + `passbolt verify` + raise RuntimeError, "Failed to login to Passbolt" unless $?.success? + end + + def fetch_secrets(secrets, from:, **) + secrets = prefixed_secrets(secrets, from: from) + raise ArgumentError, "No secrets given to fetch" if secrets.empty? + + secret_names = secrets.collect { |s| s.split("/").last } + folders = secrets_get_folders(secrets) + + # build filter conditions for each secret with its corresponding folder + filter_conditions = [] + secrets.each do |secret| + parts = secret.split("/") + secret_name = parts.last + + if parts.size > 1 + # get the folder path without the secret name + folder_path = parts[0..-2] + + # find the most nested folder for this path + current_folder = nil + current_path = [] + + folder_path.each do |folder_name| + current_path << folder_name + matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join("/") } + current_folder = matching_folders.first if matching_folders.any? + end + + if current_folder + filter_conditions << "(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder["id"].shellescape.inspect})" + end + else + # for root level secrets (no folders) + filter_conditions << "Name == #{secret_name.shellescape.inspect}" + end + end + + filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : "" + items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json` + raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success? + + items = JSON.parse(items) + found_names = items.map { |item| item["name"] } + missing_secrets = secret_names - found_names + raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any? + + items.to_h { |item| [ item["name"], item["password"] ] } + end + + def secrets_get_folders(secrets) + # extract all folder paths (both parent and nested) + folder_paths = secrets + .select { |s| s.include?("/") } + .map { |s| s.split("/")[0..-2] } # get all parts except the secret name + .uniq + + return [] if folder_paths.empty? + + all_folders = [] + + # first get all top-level folders + parent_folders = folder_paths.map(&:first).uniq + filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'" + fetch_folders = `passbolt list folders #{filter_condition} --json` + raise RuntimeError, "Could not read folders from Passbolt" unless $?.success? + + parent_folder_items = JSON.parse(fetch_folders) + all_folders.concat(parent_folder_items) + + # get nested folders for each parent + folder_paths.each do |path| + next if path.size <= 1 # skip non-nested folders + + parent = path[0] + parent_folder = parent_folder_items.find { |f| f["name"] == parent } + next unless parent_folder + + # for each nested level, get the folders using the parent's ID + current_parent = parent_folder + path[1..-1].each do |folder_name| + filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'" + fetch_nested = `passbolt list folders #{filter_condition} --json` + next unless $?.success? + + nested_folders = JSON.parse(fetch_nested) + break if nested_folders.empty? + + all_folders.concat(nested_folders) + current_parent = nested_folders.first + end + end + + # check if we found all required folders + found_paths = all_folders.map { |f| get_folder_path(f, all_folders) } + missing_paths = folder_paths.map { |path| path.join("/") } - found_paths + raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any? + + all_folders + end + + def get_folder_path(folder, all_folders, path = []) + path.unshift(folder["name"]) + return path.join("/") if folder["folder_parent_id"].to_s.empty? + + parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] } + return path.join("/") unless parent + + get_folder_path(parent, all_folders, path) + end + + def check_dependencies! + raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed? + end + + def cli_installed? + `passbolt --version 2> /dev/null` + $?.success? + end +end diff --git a/test/secrets/passbolt_adapter.rb b/test/secrets/passbolt_adapter.rb new file mode 100644 index 00000000..bcfed285 --- /dev/null +++ b/test/secrets/passbolt_adapter.rb @@ -0,0 +1,474 @@ +require "test_helper" + +class PassboltAdapterTest < SecretAdapterTestCase + setup do + `true` # Ensure $? is 0 + end + + test "fetch" do + stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) + stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) + + stub_ticks + .with("passbolt list resources --filter 'Name == \"SECRET1\" || Name == \"FSECRET1\" || Name == \"FSECRET2\"' --json") + .returns(<<~JSON) + [ + { + "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", + "folder_parent_id": "", + "name": "FSECRET1", + "username": "", + "uri": "", + "password": "fsecret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:29Z", + "modified_timestamp": "2025-02-21T06:04:29Z" + }, + { + "id": "62949b26-4957-43fe-9523-294d66861499", + "folder_parent_id": "", + "name": "FSECRET2", + "username": "", + "uri": "", + "password": "fsecret2", + "description": "", + "created_timestamp": "2025-02-21T06:04:34Z", + "modified_timestamp": "2025-02-21T06:04:34Z" + }, + { + "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", + "folder_parent_id": "", + "name": "SECRET1", + "username": "", + "uri": "", + "password": "secret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:23Z", + "modified_timestamp": "2025-02-21T06:04:23Z" + } + ] + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "SECRET1", "FSECRET1", "FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch with --from" do + stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) + stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) + + stub_ticks + .with("passbolt list folders --filter 'Name == \"my-project\"' --json") + .returns(<<~JSON) + [ + { + "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "folder_parent_id": "", + "name": "my-project", + "created_timestamp": "2025-02-21T19:52:50Z", + "modified_timestamp": "2025-02-21T19:52:50Z" + } + ] + JSON + + stub_ticks + .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --json") + .returns(<<~JSON) + [ + { + "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "FSECRET1", + "username": "", + "uri": "", + "password": "fsecret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:29Z", + "modified_timestamp": "2025-02-21T06:04:29Z" + }, + { + "id": "62949b26-4957-43fe-9523-294d66861499", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "FSECRET2", + "username": "", + "uri": "", + "password": "fsecret2", + "description": "", + "created_timestamp": "2025-02-21T06:04:34Z", + "modified_timestamp": "2025-02-21T06:04:34Z" + }, + { + "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "SECRET1", + "username": "", + "uri": "", + "password": "secret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:23Z", + "modified_timestamp": "2025-02-21T06:04:23Z" + } + ] + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "--from", "my-project", "SECRET1", "FSECRET1", "FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch with folder in secret" do + stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) + stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) + + stub_ticks + .with("passbolt list folders --filter 'Name == \"my-project\"' --json") + .returns(<<~JSON) + [ + { + "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "folder_parent_id": "", + "name": "my-project", + "created_timestamp": "2025-02-21T19:52:50Z", + "modified_timestamp": "2025-02-21T19:52:50Z" + } + ] + JSON + + stub_ticks + .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --json") + .returns(<<~JSON) + [ + { + "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "FSECRET1", + "username": "", + "uri": "", + "password": "fsecret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:29Z", + "modified_timestamp": "2025-02-21T06:04:29Z" + }, + { + "id": "62949b26-4957-43fe-9523-294d66861499", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "FSECRET2", + "username": "", + "uri": "", + "password": "fsecret2", + "description": "", + "created_timestamp": "2025-02-21T06:04:34Z", + "modified_timestamp": "2025-02-21T06:04:34Z" + }, + { + "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "SECRET1", + "username": "", + "uri": "", + "password": "secret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:23Z", + "modified_timestamp": "2025-02-21T06:04:23Z" + } + ] + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "my-project/SECRET1", "my-project/FSECRET1", "my-project/FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch from multiple folders" do + stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) + stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) + + stub_ticks + .with("passbolt list folders --filter 'Name == \"my-project\" || Name == \"other-project\"' --json") + .returns(<<~JSON) + [ + { + "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "folder_parent_id": "", + "name": "my-project", + "created_timestamp": "2025-02-21T19:52:50Z", + "modified_timestamp": "2025-02-21T19:52:50Z" + }, + { + "id": "14e11dd8-b279-4689-8bd9-fa33ebb527da", + "folder_parent_id": "", + "name": "other-project", + "created_timestamp": "2025-02-21T20:00:29Z", + "modified_timestamp": "2025-02-21T20:00:29Z" + } + ] + JSON + + stub_ticks + .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"14e11dd8-b279-4689-8bd9-fa33ebb527da\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 14e11dd8-b279-4689-8bd9-fa33ebb527da --json") + .returns(<<~JSON) + [ + { + "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "FSECRET1", + "username": "", + "uri": "", + "password": "fsecret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:29Z", + "modified_timestamp": "2025-02-21T06:04:29Z" + }, + { + "id": "62949b26-4957-43fe-9523-294d66861499", + "folder_parent_id": "14e11dd8-b279-4689-8bd9-fa33ebb527da", + "name": "FSECRET2", + "username": "", + "uri": "", + "password": "fsecret2", + "description": "", + "created_timestamp": "2025-02-21T06:04:34Z", + "modified_timestamp": "2025-02-21T06:04:34Z" + }, + { + "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "SECRET1", + "username": "", + "uri": "", + "password": "secret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:23Z", + "modified_timestamp": "2025-02-21T06:04:23Z" + } + ] + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "my-project/SECRET1", "my-project/FSECRET1", "other-project/FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch from nested folder" do + stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) + stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) + + stub_ticks + .with("passbolt list folders --filter 'Name == \"my-project\"' --json") + .returns(<<~JSON) + [ + { + "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "folder_parent_id": "", + "name": "my-project", + "created_timestamp": "2025-02-21T19:52:50Z", + "modified_timestamp": "2025-02-21T19:52:50Z" + } + ] + JSON + + stub_ticks + .with("passbolt list folders --filter 'Name == \"subfolder\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\"' --json") + .returns(<<~JSON) + [ + { + "id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "subfolder", + "created_timestamp": "2025-02-21T19:52:50Z", + "modified_timestamp": "2025-02-21T19:52:50Z" + } + ] + JSON + + stub_ticks + .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET2\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 6a3f21fc-aa40-4ba9-852c-7477fdd0310d --json") + .returns(<<~JSON) + [ + { + "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", + "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "name": "FSECRET1", + "username": "", + "uri": "", + "password": "fsecret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:29Z", + "modified_timestamp": "2025-02-21T06:04:29Z" + }, + { + "id": "62949b26-4957-43fe-9523-294d66861499", + "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "name": "FSECRET2", + "username": "", + "uri": "", + "password": "fsecret2", + "description": "", + "created_timestamp": "2025-02-21T06:04:34Z", + "modified_timestamp": "2025-02-21T06:04:34Z" + }, + { + "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", + "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "name": "SECRET1", + "username": "", + "uri": "", + "password": "secret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:23Z", + "modified_timestamp": "2025-02-21T06:04:23Z" + } + ] + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "--from", "my-project/subfolder", "SECRET1", "FSECRET1", "FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch from nested folder in secret" do + stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) + stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) + + stub_ticks + .with("passbolt list folders --filter 'Name == \"my-project\"' --json") + .returns(<<~JSON) + [ + { + "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "folder_parent_id": "", + "name": "my-project", + "created_timestamp": "2025-02-21T19:52:50Z", + "modified_timestamp": "2025-02-21T19:52:50Z" + } + ] + JSON + + stub_ticks + .with("passbolt list folders --filter 'Name == \"subfolder\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\"' --json") + .returns(<<~JSON) + [ + { + "id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", + "name": "subfolder", + "created_timestamp": "2025-02-21T19:52:50Z", + "modified_timestamp": "2025-02-21T19:52:50Z" + } + ] + JSON + + stub_ticks + .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET2\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 6a3f21fc-aa40-4ba9-852c-7477fdd0310d --json") + .returns(<<~JSON) + [ + { + "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", + "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "name": "FSECRET1", + "username": "", + "uri": "", + "password": "fsecret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:29Z", + "modified_timestamp": "2025-02-21T06:04:29Z" + }, + { + "id": "62949b26-4957-43fe-9523-294d66861499", + "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "name": "FSECRET2", + "username": "", + "uri": "", + "password": "fsecret2", + "description": "", + "created_timestamp": "2025-02-21T06:04:34Z", + "modified_timestamp": "2025-02-21T06:04:34Z" + }, + { + "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", + "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", + "name": "SECRET1", + "username": "", + "uri": "", + "password": "secret1", + "description": "", + "created_timestamp": "2025-02-21T06:04:23Z", + "modified_timestamp": "2025-02-21T06:04:23Z" + } + ] + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "my-project/subfolder/SECRET1", "my-project/subfolder/FSECRET1", "my-project/subfolder/FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch without CLI installed" do + stub_ticks_with("passbolt --version 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "HOST", "PORT"))) + end + + assert_equal "Passbolt CLI is not installed", error.message + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "passbolt" ] + end + end +end