Add GCP Secret Manager adapter
This commit is contained in:
@@ -3,6 +3,7 @@ module Kamal::Secrets::Adapters
|
|||||||
def self.lookup(name)
|
def self.lookup(name)
|
||||||
name = "one_password" if name.downcase == "1password"
|
name = "one_password" if name.downcase == "1password"
|
||||||
name = "last_pass" if name.downcase == "lastpass"
|
name = "last_pass" if name.downcase == "lastpass"
|
||||||
|
name = "gcp_secret_manager" if %w[gcp secret_manager].include? name.downcase
|
||||||
adapter_class(name)
|
adapter_class(name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
125
lib/kamal/secrets/adapters/gcp_secret_manager.rb
Normal file
125
lib/kamal/secrets/adapters/gcp_secret_manager.rb
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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:
|
||||||
|
# - Account
|
||||||
|
# - GCP project
|
||||||
|
# - Service account impersonation
|
||||||
|
#
|
||||||
|
# Syntax:
|
||||||
|
# ACCOUNT: USER | USER "," DELEGATION_CHAIN
|
||||||
|
# USER: DEFAULT_USER | EMAIL
|
||||||
|
# DELEGATION_CHAIN: EMAIL | EMAIL "," DELEGATION_CHAIN
|
||||||
|
# EMAIL: <The email address of the user or service account, like "my-user@example.com" >
|
||||||
|
# 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?
|
||||||
|
raise RuntimeError, "gcloud is not authenticated, please run `gcloud auth login`"
|
||||||
|
end
|
||||||
|
|
||||||
|
user, impersonate_service_account = parse_account(account)
|
||||||
|
|
||||||
|
{
|
||||||
|
user: user,
|
||||||
|
impersonate_service_account: impersonate_service_account
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_secrets(secrets, account:, session:)
|
||||||
|
# puts("secrets spec: #{secrets.inspect}")
|
||||||
|
{}.tap do |results|
|
||||||
|
secrets_with_metadata(secrets).each do |secret, metadata|
|
||||||
|
project, secret_name, secret_version = metadata
|
||||||
|
item_name = project == "default" ? secret_name : "#{project}/#{secret_name}"
|
||||||
|
results[item_name] = fetch_secret(session, project, secret_name, secret_version)
|
||||||
|
raise RuntimeError, "Could not read #{item_name} from Google Secret Manager" unless $?.success?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_secret(session, project, secret_name, secret_version)
|
||||||
|
secret = run_command("secrets versions access #{secret_version} --secret=#{secret_name.shellescape}", session: session, project: project)
|
||||||
|
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, session: nil, project: nil)
|
||||||
|
full_command = [ "gcloud", command ]
|
||||||
|
full_command << "--project=#{project}" unless project == "default"
|
||||||
|
full_command << "--account=#{session[:user]}" unless session[:user] == "default"
|
||||||
|
full_command << "--impersonate-service-account=#{session[:impersonate_service_account]}" if session[:impersonate_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)
|
||||||
|
return "default", nil if account == "default"
|
||||||
|
|
||||||
|
parts = account.split(",", 2)
|
||||||
|
|
||||||
|
if parts.length == 2
|
||||||
|
return parts.shift, parts.shift
|
||||||
|
elsif parts.length != 1
|
||||||
|
raise RuntimeError, "Invalid account, too many parts: #{account}"
|
||||||
|
elsif is_user?(account)
|
||||||
|
return account, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
raise RuntimeError, "Invalid account, not a user: #{account}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_user?(candidate)
|
||||||
|
candidate.include?("@")
|
||||||
|
end
|
||||||
|
end
|
||||||
211
test/secrets/gcp_secret_manager_adapter_test.rb
Normal file
211
test/secrets/gcp_secret_manager_adapter_test.rb
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
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 = { "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("[]")
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user