Skip to content

Commit

Permalink
Merge pull request #1236 from andrelaszlo/gcp_secret_manager_adapter
Browse files Browse the repository at this point in the history
Add GCP Secret Manager adapter
  • Loading branch information
djmb authored Jan 17, 2025
2 parents a7b2ef5 + 06f2cb2 commit 93133cd
Show file tree
Hide file tree
Showing 3 changed files with 333 additions and 0 deletions.
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"
name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm"
adapter_class(name)
end
Expand Down
112 changes: 112 additions & 0 deletions lib/kamal/secrets/adapters/gcp_secret_manager.rb
Original file line number Diff line number Diff line change
@@ -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: <The email address of the user or service account, like "[email protected]" >
# DEFAULT_USER: "default"
#
# Some valid examples:
# - "[email protected]" sets the user
# - "[email protected]|[email protected]" will use my-user and enable service account impersonation as my-service-user
# - "default" will use the default user and no impersonation
# - "default|[email protected]" will use the default user, and enable service account impersonation as my-service-user
# - "default|[email protected],[email protected]" 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
220 changes: 220 additions & 0 deletions test/secrets/gcp_secret_manager_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -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 protected]")

json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "[email protected]")))

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: "[email protected]")

json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "default|[email protected]")))

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: "[email protected]", impersonate_service_account: "[email protected],[email protected]")

json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "[email protected]|[email protected],[email protected]")))

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 protected]", impersonate_service_account: "[email protected]")

json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "[email protected]|[email protected]")))

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 protected]",
"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

0 comments on commit 93133cd

Please sign in to comment.