diff --git a/lib/kamal/secrets/adapters/passbolt.rb b/lib/kamal/secrets/adapters/passbolt.rb index b0eb1b10..90e9ee27 100644 --- a/lib/kamal/secrets/adapters/passbolt.rb +++ b/lib/kamal/secrets/adapters/passbolt.rb @@ -12,11 +12,42 @@ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base def fetch_secrets(secrets, from:, **) secrets = prefixed_secrets(secrets, from: from) - flags = secrets_get_flags(secrets) - secret_names = secrets.collect { |s| s.split("/").last } + raise ArgumentError, "No secrets given to fetch" if secrets.empty? - filter_condition = secret_names.any? ? "--filter '#{secret_names.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'" : "" - items = `passbolt list resources #{filter_condition} #{flags} --json` + 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) @@ -27,26 +58,65 @@ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base items.to_h { |item| [item["name"], item["password"]] } end - def secrets_get_flags(secrets) - folders = secrets + def secrets_get_folders(secrets) + # extract all folder paths (both parent and nested) + folder_paths = secrets .select { |s| s.include?("/") } - .map { |s| s.split("/").first } + .map { |s| s.split("/")[0..-2] } # get all parts except the secret name .uniq - if folders.any? - folder_ids = folders.map do |folder| - fetch_folder = `passbolt list folders --filter 'Name == \"#{folder.shellescape}\"' --json` - raise RuntimeError, "Could not read folder #{folder} from Passbolt" unless $?.success? + return [] if folder_paths.empty? - folder_items = JSON.parse(fetch_folder) - folder_item = folder_items.find { |item| item["name"] == folder } - folder_item["id"] + 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 - - "--folder #{folder_ids.join(" --folder ")}" - else - "" 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!