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

WIP: Add Buildkite as a trusted publisher #5437

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/controllers/oidc/rubygem_trusted_publishers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ def index
end

def new
trusted_publisher = if params[:trusted_publisher] == "buildkite"
buildkite_trusted_publisher
else
gh_actions_trusted_publisher
end

render OIDC::RubygemTrustedPublishers::NewView.new(
rubygem_trusted_publisher: @rubygem.oidc_rubygem_trusted_publishers.new(trusted_publisher: gh_actions_trusted_publisher)
rubygem_trusted_publisher: @rubygem.oidc_rubygem_trusted_publishers.new(trusted_publisher: trusted_publisher)
)
end

Expand Down Expand Up @@ -60,6 +66,10 @@ def find_rubygem_trusted_publisher
@rubygem_trusted_publisher = authorize @rubygem.oidc_rubygem_trusted_publishers.find(params[:id])
end

def buildkite_trusted_publisher
OIDC::TrustedPublisher::Buildkite.new
end

def gh_actions_trusted_publisher
github_params = helpers.github_params(@rubygem)

Expand Down
3 changes: 3 additions & 0 deletions app/models/oidc/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class OIDC::Provider < ApplicationRecord
has_many :audits, as: :auditable, dependent: :nullify

GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com".freeze
BUILDKITE_ISSUER = "https://agent.buildkite.com".freeze

def self.github_actions
find_by(issuer: GITHUB_ACTIONS_ISSUER)
Expand Down Expand Up @@ -43,6 +44,8 @@ def trusted_publisher_class
case issuer
when GITHUB_ACTIONS_ISSUER
OIDC::TrustedPublisher::GitHubAction
when BUILDKITE_ISSUER
OIDC::TrustedPublisher::Buildkite
end
end

Expand Down
2 changes: 1 addition & 1 deletion app/models/oidc/trusted_publisher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ def self.table_name_prefix
end

def self.all
[GitHubAction]
[GitHubAction, Buildkite]
end
end
127 changes: 127 additions & 0 deletions app/models/oidc/trusted_publisher/buildkite.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
class OIDC::TrustedPublisher::Buildkite < ApplicationRecord
has_many :rubygem_trusted_publishers, class_name: "OIDC::RubygemTrustedPublisher", as: :trusted_publisher, dependent: :destroy,
inverse_of: :trusted_publisher
has_many :pending_trusted_publishers, class_name: "OIDC::PendingTrustedPublisher", as: :trusted_publisher, dependent: :destroy,
inverse_of: :trusted_publisher
has_many :rubygems, through: :rubygem_trusted_publishers
has_many :api_keys, dependent: :destroy, inverse_of: :owner, as: :owner

validates :organization_slug, :pipeline_slug,
presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }

validate :unique_publisher

def self.for_claims(claims)
organization_slug = claims.fetch(:organization_slug)
pipeline_slug = claims.fetch(:pipeline_slug)

where(organization_slug:, pipeline_slug:).first!
end

def self.permitted_attributes
%i[organization_slug pipeline_slug]
end

def self.build_trusted_publisher(params)
params = params.reverse_merge(organization_slug: nil, pipeline_slug: nil)
find_or_initialize_by(params)
end

def self.publisher_name = "Buildkite"

def payload
{
name:,
organization_slug:,
pipeline_slug:
}
end

delegate :as_json, to: :payload

def organization_slug_condition
OIDC::AccessPolicy::Statement::Condition.new(
operator: "string_equals",
claim: "organization_slug",
value: organization_slug
)
end

def pipeline_slug_condition
OIDC::AccessPolicy::Statement::Condition.new(
operator: "string_equals",
claim: "pipeline_slug",
value: pipeline_slug
)
end

def audience_condition
OIDC::AccessPolicy::Statement::Condition.new(
operator: "string_equals",
claim: "aud",
value: Gemcutter::HOST
)
end

def to_access_policy(jwt)
# TODO what to do with jwt here?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're OK to ignore it, since there are no cross-claim consistency checks we need to enforce, unlike with GitHub (where we make sure the workflow source matches up with the commit being built)

# TODO should we be checking the audience claim?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, audience claim should be checked, same as is done for the GHA publisher

common_conditions = [organization_slug_condition, pipeline_slug_condition].compact
OIDC::AccessPolicy.new(
statements: [
OIDC::AccessPolicy::Statement.new(
effect: "allow",
principal: OIDC::AccessPolicy::Statement::Principal.new(
oidc: OIDC::Provider::BUILDKITE_ISSUER
),
conditions: common_conditions
)
]
)
end

#class SigstorePolicy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add a push test that uses a build kite trusted publisher and includes an attestation?

# def initialize(trusted_publisher)
# @trusted_publisher = trusted_publisher
# end

# def verify(cert)
# # 1.3.6.1.4.1.57264.1.14 is `Source Repository Ref` - AKA Branch or Tag
# ref = cert.openssl.find_extension("1.3.6.1.4.1.57264.1.14")&.value_der&.then { OpenSSL::ASN1.decode(_1).value }
# Sigstore::Policy::Identity.new(
# identity: "https://github.com/#{@trusted_publisher.repository}/#{@trusted_publisher.workflow_slug}@#{ref}",
# issuer: OIDC::Provider::BUILDKITE_ISSUER
# ).verify(cert)
# end
#end

#def to_sigstore_identity_policy
# SigstorePolicy.new(self)
#end

def name
"#{self.class.publisher_name} #{organization_slug}/#{pipeline_slug}"
end

def owns_gem?(rubygem) = rubygem_trusted_publishers.exists?(rubygem: rubygem)

def ld_context
LaunchDarkly::LDContext.create(
key: "#{model_name.singular}-key-#{id}",
kind: "trusted_publisher",
name: name
)
end

private

def unique_publisher
return unless self.class.exists?(
organization_slug: organization_slug,
pipeline_slug: pipeline_slug,
)

errors.add(:base, "publisher already exists")
end

end
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

class OIDC::TrustedPublisher::Buildkite::FormComponent < ApplicationComponent
prop :buildkite_form, reader: :public

def view_template
buildkite_form.fields_for :trusted_publisher do |trusted_publisher_form|
field trusted_publisher_form, :text_field, :organization_slug, autocomplete: :off
field trusted_publisher_form, :text_field, :pipeline_slug, autocomplete: :off
end
end

private

def field(form, type, name, optional: false, **)
form.label name, class: "form__label" do
plain form.object.class.human_attribute_name(name)
span(class: "t-text--s") { " (#{t('form.optional')})" } if optional
end
form.send(type, name, class: helpers.class_names("form__input", "tw-border tw-border-red-500" => form.object.errors.include?(name)), **)
p(class: "form__field__instructions") { t("oidc.trusted_publisher.buildkite.#{name}_help_html") }
end
end

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class OIDC::TrustedPublisher::Buildkite::TableComponent < ApplicationComponent
prop :buildkite, reader: :public

def view_template
dl(class: "tw-flex tw-flex-col sm:tw-grid sm:tw-grid-cols-2 tw-items-baseline tw-gap-4 full-width overflow-wrap") do
dt(class: "description__heading ") { "Organization Slug" }
dd { code { buildkite.organization_slug } }

dt(class: "description__heading ") { "Pipeline Slug" }
dd { code { buildkite.pipeline_slug } }
end
end
end
3 changes: 2 additions & 1 deletion app/views/oidc/rubygem_trusted_publishers/index_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def view_template
end

p do
button_to t(".create"), new_rubygem_trusted_publisher_path(rubygem.slug), class: "form__submit", method: :get
button_to t(".create") + " Buildkite", new_rubygem_trusted_publisher_path(rubygem.slug), params: {trusted_publisher: "buildkite"}, class: "form__submit", method: :get
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of multiple buttons here, what if the trusted publisher new view reloaded the form when the selector for trusted publisher type was changed?

button_to t(".create") + " Github Actions", new_rubygem_trusted_publisher_path(rubygem.slug), params: {trusted_publisher: "github_actions"}, class: "form__submit", method: :get
end

header(class: "gems__header push--s") do
Expand Down
25 changes: 17 additions & 8 deletions app/views/oidc/rubygem_trusted_publishers/new_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,31 @@ def view_template
title_content

div(class: "t-body") do
p do
"New Trusted Publisher: #{rubygem_trusted_publisher.trusted_publisher.class.publisher_name}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n ?

end
form_with(
model: rubygem_trusted_publisher,
url: rubygem_trusted_publishers_path(rubygem_trusted_publisher.rubygem.slug)
) do |f|
f.label :trusted_publisher_type, class: "form__label"
f.select :trusted_publisher_type, OIDC::TrustedPublisher.all.map { |type|
[type.publisher_name, type.polymorphic_name]
}, {}, class: "form__input form__select"

render OIDC::TrustedPublisher::GitHubAction::FormComponent.new(
github_action_form: f
)
f.hidden_field :trusted_publisher_type

render form_component(f)
f.submit class: "form__submit"
end
end
end

delegate :rubygem, to: :rubygem_trusted_publisher

private

def form_component(form)
case rubygem_trusted_publisher.trusted_publisher
when OIDC::TrustedPublisher::Buildkite then OIDC::TrustedPublisher::Buildkite::FormComponent.new(buildkite_form: form)
when OIDC::TrustedPublisher::GitHubAction then OIDC::TrustedPublisher::GitHubAction::FormComponent.new(github_action_form: form)
else
raise "oh no"
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%= render OIDC::TrustedPublisher::Buildkite::TableComponent.new(buildkite:) %>

Original file line number Diff line number Diff line change
@@ -1 +1 @@
<%= render OIDC::TrustedPublisher::GitHubAction::TableComponent.new(github_action:) %>
<%= render OIDC::TrustedPublisher::GitHubAction::TableComponent.new(github_action:) %>
3 changes: 3 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,9 @@ en:
title: "New Pending Trusted Publisher"
trusted_publisher:
unsupported_type: "Unsupported trusted publisher type"
buildkite:
organization_slug_help_html: "The Buildkite organization slug that owns the pipeline"
pipeline_slug_help_html: "The slug of the Buildkite Pipeline that runs the publishing workflow"
github_actions:
repository_owner_help_html: "The GitHub organization name or GitHub username that owns the repository"
repository_name_help_html: "The name of the GitHub repository that contains the publishing workflow"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class CreateOIDCTrustedPublisherBuildkite < ActiveRecord::Migration[8.0]
disable_ddl_transaction!

def change
create_table :oidc_trusted_publisher_buildkites do |t|
t.string :organization_slug, null: false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these two slugs both immutable, or is it possible for someone to take over an organization name that was previously owned by someone else?

t.string :pipeline_slug, null: false

t.timestamps
end

add_index :oidc_trusted_publisher_buildkites,
%i[organization_slug pipeline_slug],
unique: true, name: "index_oidc_trusted_publisher_buildkite_claims", algorithm: :concurrently
end
end
12 changes: 10 additions & 2 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2024_11_04_065953) do
ActiveRecord::Schema[8.0].define(version: 2025_02_04_190405) do
# These are extensions that must be enabled in order to support this database
enable_extension "hstore"
enable_extension "pg_catalog.plpgsql"
enable_extension "pgcrypto"
enable_extension "plpgsql"

create_table "admin_github_users", force: :cascade do |t|
t.string "login"
Expand Down Expand Up @@ -409,6 +409,14 @@
t.index ["trusted_publisher_type", "trusted_publisher_id"], name: "index_oidc_rubygem_trusted_publishers_on_trusted_publisher"
end

create_table "oidc_trusted_publisher_buildkites", force: :cascade do |t|
t.string "organization_slug", null: false
t.string "pipeline_slug", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["organization_slug", "pipeline_slug"], name: "index_oidc_trusted_publisher_buildkite_claims", unique: true
end

create_table "oidc_trusted_publisher_github_actions", force: :cascade do |t|
t.string "repository_owner", null: false
t.string "repository_name", null: false
Expand Down
18 changes: 18 additions & 0 deletions test/factories/oidc/api_key_role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,23 @@
]
}
end

trait :buildkite do
provider factory: :oidc_provider_buildkite
sequence(:name) { |n| "Buildkite Pusher #{n}" }
access_policy do
{
statements: [
{ effect: "allow",
principal: { oidc: provider.issuer },
conditions: [
{ operator: "string_equals", claim: "organization_slug", value: "example-org" }
] }
]
}
end
end

factory :oidc_api_key_role_buildkite, traits: [:buildkite]
end
end
Loading