diff --git a/lib/kamal/secrets/adapters.rb b/lib/kamal/secrets/adapters.rb index 439c7208..e51c73ef 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 = "gcp_secret_manager" if name.downcase == "gcp" adapter_class(name) end diff --git a/lib/kamal/secrets/adapters/gcp_secret_manager.rb b/lib/kamal/secrets/adapters/gcp_secret_manager.rb new file mode 100644 index 00000000..b8dfebf3 --- /dev/null +++ b/lib/kamal/secrets/adapters/gcp_secret_manager.rb @@ -0,0 +1,112 @@ +class Kamal::Secrets::Adapters::GcpSecretManager < Kamal::Secrets::Adapters::Base + private + def login(account) + # Since only the account option is passed from the cli, we'll use it for both account and service account + # impersonation. + # + # Syntax: + # ACCOUNT: USER | USER "|" DELEGATION_CHAIN + # USER: DEFAULT_USER | EMAIL + # DELEGATION_CHAIN: EMAIL | EMAIL "," DELEGATION_CHAIN + # EMAIL: + # DEFAULT_USER: "default" + # + # Some valid examples: + # - "my-user@example.com" sets the user + # - "my-user@example.com|my-service-user@example.com" will use my-user and enable service account impersonation as my-service-user + # - "default" will use the default user and no impersonation + # - "default|my-service-user@example.com" will use the default user, and enable service account impersonation as my-service-user + # - "default|my-service-user@example.com,another-service-user@example.com" same as above, but with an impersonation delegation chain + + if !logged_in? + `gcloud auth login` + raise RuntimeError, "gcloud is not authenticated, please run `gcloud auth login`" if !logged_in? + end + + nil + end + + def fetch_secrets(secrets, account:, session:) + user, service_account = parse_account(account) + + {}.tap do |results| + secrets_with_metadata(secrets).each do |secret, (project, secret_name, secret_version)| + item_name = "#{project}/#{secret_name}" + results[item_name] = fetch_secret(project, secret_name, secret_version, user, service_account) + raise RuntimeError, "Could not read #{item_name} from Google Secret Manager" unless $?.success? + end + end + end + + def fetch_secret(project, secret_name, secret_version, user, service_account) + secret = run_command( + "secrets versions access #{secret_version.shellescape} --secret=#{secret_name.shellescape}", + project: project, + user: user, + service_account: service_account + ) + Base64.decode64(secret.dig("payload", "data")) + end + + # The secret needs to at least contain a secret name, but project name, and secret version can also be specified. + # + # The string "default" can be used to refer to the default project configured for gcloud. + # + # The version can be either the string "latest", or a version number. + # + # The following formats are valid: + # + # - The following are all equivalent, and sets project: default, secret name: my-secret, version: latest + # - "my-secret" + # - "default/my-secret" + # - "default/my-secret/latest" + # - "my-secret/latest" in combination with --from=default + # - "my-secret/123" (only in combination with --from=some-project) -> project: some-project, secret name: my-secret, version: 123 + # - "some-project/my-secret/123" -> project: some-project, secret name: my-secret, version: 123 + def secrets_with_metadata(secrets) + {}.tap do |items| + secrets.each do |secret| + parts = secret.split("/") + parts.unshift("default") if parts.length == 1 + project = parts.shift + secret_name = parts.shift + secret_version = parts.shift || "latest" + + items[secret] = [ project, secret_name, secret_version ] + end + end + end + + def run_command(command, project: "default", user: "default", service_account: nil) + full_command = [ "gcloud", command ] + full_command << "--project=#{project.shellescape}" unless project == "default" + full_command << "--account=#{user.shellescape}" unless user == "default" + full_command << "--impersonate-service-account=#{service_account.shellescape}" if service_account + full_command << "--format=json" + full_command = full_command.join(" ") + + result = `#{full_command}`.strip + JSON.parse(result) + end + + def check_dependencies! + raise RuntimeError, "gcloud CLI is not installed" unless cli_installed? + end + + def cli_installed? + `gcloud --version 2> /dev/null` + $?.success? + end + + def logged_in? + JSON.parse(`gcloud auth list --format=json`).any? + end + + def parse_account(account) + account.split("|", 2) + end + + def is_user?(candidate) + candidate.include?("@") + end +end diff --git a/test/secrets/gcp_secret_manager_adapter_test.rb b/test/secrets/gcp_secret_manager_adapter_test.rb new file mode 100644 index 00000000..341f42e9 --- /dev/null +++ b/test/secrets/gcp_secret_manager_adapter_test.rb @@ -0,0 +1,220 @@ +require "test_helper" + +class GcpSecretManagerAdapterTest < SecretAdapterTestCase + test "fetch" do + stub_gcloud_version + stub_authenticated + stub_mypassword + + json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) + + expected_json = { "default/mypassword"=>"secret123" } + + assert_equal expected_json, json + end + + test "fetch unauthenticated" do + stub_ticks.with("gcloud --version 2> /dev/null") + + stub_mypassword + stub_unauthenticated + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "mypassword"))) + end + + assert_match(/not authenticated/, error.message) + end + + test "fetch with from" do + stub_gcloud_version + stub_authenticated + stub_items(0, project: "other-project") + stub_items(1, project: "other-project") + stub_items(2, project: "other-project") + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "other-project", "item1", "item2", "item3"))) + + expected_json = { + "other-project/item1"=>"secret1", "other-project/item2"=>"secret2", "other-project/item3"=>"secret3" + } + + assert_equal expected_json, json + end + + test "fetch with multiple projects" do + stub_gcloud_version + stub_authenticated + stub_items(0, project: "some-project") + stub_items(1, project: "project-confidence") + stub_items(2, project: "manhattan-project") + + json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1", "project-confidence/item2", "manhattan-project/item3"))) + + expected_json = { + "some-project/item1"=>"secret1", "project-confidence/item2"=>"secret2", "manhattan-project/item3"=>"secret3" + } + + assert_equal expected_json, json + end + + test "fetch with specific version" do + stub_gcloud_version + stub_authenticated + stub_items(0, project: "some-project", version: "123") + + json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123"))) + + expected_json = { + "some-project/item1"=>"secret1" + } + + assert_equal expected_json, json + end + + test "fetch with non-default account" do + stub_gcloud_version + stub_authenticated + stub_items(0, project: "some-project", version: "123", account: "email@example.com") + + json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "email@example.com"))) + + expected_json = { + "some-project/item1"=>"secret1" + } + + assert_equal expected_json, json + end + + test "fetch with service account impersonation" do + stub_gcloud_version + stub_authenticated + stub_items(0, project: "some-project", version: "123", impersonate_service_account: "service-user@example.com") + + json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "default|service-user@example.com"))) + + expected_json = { + "some-project/item1"=>"secret1" + } + + assert_equal expected_json, json + end + + test "fetch with delegation chain and specific user" do + stub_gcloud_version + stub_authenticated + stub_items(0, project: "some-project", version: "123", account: "user@example.com", impersonate_service_account: "service-user@example.com,service-user2@example.com") + + json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "user@example.com|service-user@example.com,service-user2@example.com"))) + + expected_json = { + "some-project/item1"=>"secret1" + } + + assert_equal expected_json, json + end + + test "fetch with non-default account and service account impersonation" do + stub_gcloud_version + stub_authenticated + stub_items(0, project: "some-project", version: "123", account: "email@example.com", impersonate_service_account: "service-user@example.com") + + json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "email@example.com|service-user@example.com"))) + + expected_json = { + "some-project/item1"=>"secret1" + } + + assert_equal expected_json, json + end + + test "fetch without CLI installed" do + stub_gcloud_version(succeed: false) + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "item1"))) + end + assert_equal "gcloud CLI is not installed", error.message + end + + private + def run_command(*command, account: "default") + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "gcp_secret_manager", + "--account", account ] + end + end + + def stub_gcloud_version(succeed: true) + stub_ticks_with("gcloud --version 2> /dev/null", succeed: succeed) + end + + def stub_authenticated + stub_ticks + .with("gcloud auth list --format=json") + .returns(<<~JSON) + [ + { + "account": "email@example.com", + "status": "ACTIVE" + } + ] + JSON + end + + def stub_unauthenticated + stub_ticks + .with("gcloud auth list --format=json") + .returns("[]") + + stub_ticks + .with("gcloud auth login") + .returns(<<~JSON) + { + "expired": false, + "valid": true + } + JSON + end + + def stub_mypassword + stub_ticks + .with("gcloud secrets versions access latest --secret=mypassword --format=json") + .returns(<<~JSON) + { + "name": "projects/000000000/secrets/mypassword/versions/1", + "payload": { + "data": "c2VjcmV0MTIz", + "dataCrc32c": "2522602764" + } + } + JSON + end + + def stub_items(n, project: nil, account: nil, version: "latest", impersonate_service_account: nil) + payloads = [ + { data: "c2VjcmV0MQ==", checksum: 1846998209 }, + { data: "c2VjcmV0Mg==", checksum: 2101741365 }, + { data: "c2VjcmV0Mw==", checksum: 2402124854 } + ] + stub_ticks + .with("gcloud secrets versions access #{version} " \ + "--secret=item#{n + 1}" \ + "#{" --project=#{project}" if project}" \ + "#{" --account=#{account}" if account}" \ + "#{" --impersonate-service-account=#{impersonate_service_account}" if impersonate_service_account} " \ + "--format=json") + .returns(<<~JSON) + { + "name": "projects/000000001/secrets/item1/versions/1", + "payload": { + "data": "#{payloads[n][:data]}", + "dataCrc32c": "#{payloads[n][:checksum]}" + } + } + JSON + end +end