Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GCP Secret Manager adapter #1236

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions lib/kamal/secrets/adapters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
123 changes: 123 additions & 0 deletions lib/kamal/secrets/adapters/gcp_secret_manager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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: <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
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
# - "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`"
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
end

user, impersonate_service_account = parse_account(account)

{
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
user: user,
impersonate_service_account: impersonate_service_account
}
end

def fetch_secrets(secrets, account:, session:)
# puts("secrets spec: #{secrets.inspect}")
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
{}.tap do |results|
secrets_with_metadata(secrets).each do |secret, metadata|
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
project, secret_name, secret_version = metadata
item_name = project == "default" ? secret_name : "#{project}/#{secret_name}"
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
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)
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
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"
andrelaszlo marked this conversation as resolved.
Show resolved Hide resolved
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 changes: 211 additions & 0 deletions test/secrets/gcp_secret_manager_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -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