diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index 0c9dd5cb..d7e99b31 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -2,9 +2,12 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base desc "fetch [ITEM] [FIELDS...]", "Fetch secrets from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" option :account, type: :string, aliases: "-a", required: true, desc: "The account identifier or username" - def fetch(item, *fields) + option :location, type: :string, aliases: "-a", required: false, desc: "A vault or folder to fetch the secrets from" + def fetch(*secrets) ENV["KAMAL_SECRETS_KILL_PARENT"] = "1" - puts JSON.dump(adapter(options[:adapter]).fetch(item, fields, account: options[:account])).shellescape + + results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :location).symbolize_keys) + puts JSON.dump(results).shellescape end desc "extract", "Extract a single secret from the results of a fetch call" diff --git a/lib/kamal/secrets/adapters.rb b/lib/kamal/secrets/adapters.rb index 2ad1dcdf..439c7208 100644 --- a/lib/kamal/secrets/adapters.rb +++ b/lib/kamal/secrets/adapters.rb @@ -1,12 +1,14 @@ +require "active_support/core_ext/string/inflections" module Kamal::Secrets::Adapters def self.lookup(name) - case name - when "1password" - Kamal::Secrets::Adapters::OnePassword.new - else - Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new - end - rescue NameError + name = "one_password" if name.downcase == "1password" + name = "last_pass" if name.downcase == "lastpass" + adapter_class(name) + end + + def self.adapter_class(name) + Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new + rescue NameError => e raise RuntimeError, "Unknown secrets adapter: #{name}" end end diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index f5f1f8e1..ee9c9ce8 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -1,40 +1,61 @@ -class Kamal::Secrets::Adapters::OnePassword +class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base delegate :optionize, to: Kamal::Utils - def fetch(item, fields, account: nil) - # session may be nil if logging in with the app CLI integration - session = signin(account) - vault, vault_item = item.split("/") - labels = fields.map { |field| "label=#{field}" }.join(",") - options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence) - - secrets_json = `op item get #{vault_item} #{options}`.tap do - raise RuntimeError, "Could not read #{labels} from #{vault_item} in the #{vault} 1Password vault" unless $?.success? - end - - {}.tap do |secrets| - JSON.parse(secrets_json).each do |secret_json| - # The reference is in the form `op://vault/item/field[/field]` - field = secret_json["reference"].delete_prefix("op://#{item}/") - secrets[field] = secret_json["value"] - secrets[field.split("/").last] = secret_json["value"] + private + def login(account) + unless loggedin?(account) + `op signin #{to_options(account: account, force: true, raw: true)}`.tap do + raise RuntimeError, "Failed to login to 1Password" unless $?.success? + end end end - rescue => e - $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" - Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_KILL_PARENT"] - exit 1 - end + def loggedin?(account) + `op account get --account #{account}` + $?.success? + end - private - def signin(account) - `op signin #{to_options(account: account, force: true, raw: true)}`.tap do - raise RuntimeError, "Failed to login to 1Password" unless $?.success? + def fetch_from_vault(secrets, account:, session:) + {}.tap do |results| + vaults_items_fields(secrets).map do |vault, items| + items.each do |item, fields| + fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session)) + fields_json = [ fields_json ] if fields.one? + + fields_json.each do |field_json| + # The reference is in the form `op://vault/item/field[/field]` + field = field_json["reference"].delete_suffix("/password") + results[field] = field_json["value"] + results[field.split("/").last] = field_json["value"] + end + end + end end end def to_options(**options) optionize(options.compact).join(" ") end + + def vaults_items_fields(secrets) + {}.tap do |vaults| + secrets.each do |secret| + vault, item, *fields = secret.split("/") + fields << "password" if fields.empty? + + vaults[vault] ||= {} + vaults[vault][item] ||= [] + vaults[vault][item] << fields.join(".") + end + end + end + + def op_item_get(vault, item, fields, account:, session:) + labels = fields.map { |field| "label=#{field}" }.join(",") + options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence) + + `op item get #{item} #{options}`.tap do + raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success? + end + end end