Skip to content

Commit

Permalink
Add trusted publishers
Browse files Browse the repository at this point in the history
  • Loading branch information
segiddins committed Dec 9, 2023
1 parent 05f6813 commit 89a8202
Show file tree
Hide file tree
Showing 80 changed files with 3,067 additions and 167 deletions.
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Metrics/BlockLength:
- config/environments/development.rb

Metrics/ClassLength:
Max: 356 # TODO: Lower to 100
Max: 357 # TODO: Lower to 100
Exclude:
- test/**/*

Expand Down
2 changes: 1 addition & 1 deletion app/avo/resources/api_key_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class ExpiredFilter < ScopeBooleanFilter; end
field :user, as: :belongs_to, visible: ->(_) { false }
field :owner, as: :belongs_to,
polymorphic_as: :owner,
types: [::User]
types: [::User, ::OIDC::TrustedPublisher::GitHubAction]
field :last_accessed_at, as: :date_time
field :soft_deleted_at, as: :date_time
field :soft_deleted_rubygem_name, as: :text
Expand Down
16 changes: 16 additions & 0 deletions app/avo/resources/oidc_pending_trusted_publisher_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class OIDCPendingTrustedPublisherResource < Avo::BaseResource
self.title = :id
self.includes = []
self.model_class = ::OIDC::PendingTrustedPublisher

class ExpiredFilter < ScopeBooleanFilter; end
filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } }

field :id, as: :id
# Fields generated from the model
field :rubygem_name, as: :text
field :user, as: :belongs_to
field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher
field :expires_at, as: :date_time
# add fields here
end
3 changes: 0 additions & 3 deletions app/avo/resources/oidc_provider_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ class OIDCProviderResource < Avo::BaseResource
self.title = :issuer
self.includes = []
self.model_class = ::OIDC::Provider
# self.search_query = -> do
# scope.ransack(id_eq: params[:q], m: "or").result(distinct: false)
# end

action RefreshOIDCProvider

Expand Down
11 changes: 11 additions & 0 deletions app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class OIDCRubygemTrustedPublisherResource < Avo::BaseResource
self.title = :id
self.includes = [:trusted_publisher]
self.model_class = ::OIDC::RubygemTrustedPublisher

field :id, as: :id
# Fields generated from the model
field :rubygem, as: :belongs_to
field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher
# add fields here
end
19 changes: 19 additions & 0 deletions app/avo/resources/oidc_trusted_publisher_github_action_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class OIDCTrustedPublisherGitHubActionResource < Avo::BaseResource
self.title = :name
self.includes = []
self.model_class = ::OIDC::TrustedPublisher::GitHubAction

field :id, as: :id
# Fields generated from the model
field :repository_owner, as: :text
field :repository_name, as: :text
field :repository_owner_id, as: :text
field :workflow_filename, as: :text
field :environment, as: :text
# add fields here
#
field :rubygem_trusted_publishers, as: :has_many
field :pending_trusted_publishers, as: :has_many
field :rubygems, as: :has_many, through: :rubygem_trusted_publishers
field :api_keys, as: :has_many, inverse_of: :owner
end
3 changes: 3 additions & 0 deletions app/avo/resources/rubygem_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class IndexedFilter < ScopeBooleanFilter; end
field :linkset, as: :has_one
field :gem_download, as: :has_one

field :link_verifications, as: :has_many
field :oidc_rubygem_trusted_publishers, as: :has_many

field :audits, as: :has_many
end
end
71 changes: 71 additions & 0 deletions app/controllers/api/v1/oidc/trusted_publisher_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
class Api::V1::OIDC::TrustedPublisherController < Api::BaseController
include ApiKeyable

before_action :decode_jwt
before_action :find_provider
before_action :verify_signature
before_action :find_trusted_publisher
before_action :validate_claims

class UnsupportedIssuer < StandardError; end
class UnverifiedJWT < StandardError; end

rescue_from(
UnsupportedIssuer, UnverifiedJWT,
JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound,
OIDC::AccessPolicy::AccessError,
with: :render_not_found
)

def exchange_token
key = generate_unique_rubygems_key
iat = Time.at(@jwt[:iat].to_i, in: "UTC")
api_key = @trusted_publisher.api_keys.create!(
hashed_key: hashed_key(key),
name: "#{@trusted_publisher.name} #{iat.iso8601}",
push_rubygem: true,
expires_at: 15.minutes.from_now
)

render json: {
rubygems_api_key: key,
name: api_key.name,
scopes: api_key.enabled_scopes,
gem: api_key.rubygem,
expires_at: api_key.expires_at
}.compact, status: :created
end

private

def decode_jwt
@jwt = JSON::JWT.decode_compact_serialized(params.require(:jwt), :skip_verification)
rescue JSON::JWT::InvalidFormat, JSON::ParserError, ArgumentError
# invalid base64 raises ArgumentError
render_bad_request
end

def find_provider
@provider = OIDC::Provider.find_by!(issuer: @jwt[:iss])
end

def verify_signature
raise UnverifiedJWT, "Invalid time" unless (@jwt["nbf"]..@jwt["exp"]).cover?(Time.now.to_i)
@jwt.verify!(@provider.jwks)
end

def find_trusted_publisher
unless (trusted_publisher_class = @provider.trusted_publisher_class)
raise UnsupportedIssuer, "Unsuported issuer for trusted publishing"
end
@trusted_publisher = trusted_publisher_class.for_claims(@jwt)
end

def validate_claims
@trusted_publisher.to_access_policy(@jwt).verify_access!(@jwt)
end

def render_bad_request
render json: { error: "Bad Request" }, status: :bad_request
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::OIDCPendingTrustedPublishersController < Avo::ResourcesController
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::OIDCRubygemTrustedPublishersController < Avo::ResourcesController
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::OIDCTrustedPublisherGitHubActionsController < Avo::ResourcesController
end
21 changes: 21 additions & 0 deletions app/controllers/oidc/concerns/trusted_publisher_creation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module OIDC::Concerns::TrustedPublisherCreation
extend ActiveSupport::Concern

included do
include SessionVerifiable
verify_session_before

before_action :set_trusted_publisher_type, only: %i[create]
before_action :create_params, only: %i[create]
before_action :set_page, only: :index
end

def set_trusted_publisher_type
trusted_publisher_type = params.permit(create_params_key => :trusted_publisher_type).require(create_params_key).require(:trusted_publisher_type)

@trusted_publisher_type = OIDC::TrustedPublisher.all.find { |type| type.polymorphic_name == trusted_publisher_type }

return if @trusted_publisher_type
redirect_back fallback_location: root_path, flash: { error: t("oidc.trusted_publisher.unsupported_type") }
end
end
65 changes: 65 additions & 0 deletions app/controllers/oidc/pending_trusted_publishers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
class OIDC::PendingTrustedPublishersController < ApplicationController
include OIDC::Concerns::TrustedPublisherCreation

before_action :find_pending_trusted_publisher, only: %i[destroy]

def index
trusted_publishers = current_user
.oidc_pending_trusted_publishers.unexpired.includes(:trusted_publisher)
.order(:rubygem_name, :created_at).page(@page).strict_loading
render OIDC::PendingTrustedPublishers::IndexView.new(
trusted_publishers:
)
end

def new
pending_trusted_publisher = current_user.oidc_pending_trusted_publishers.new(trusted_publisher: OIDC::TrustedPublisher::GitHubAction.new)
render OIDC::PendingTrustedPublishers::NewView.new(
pending_trusted_publisher:
)
end

def create
trusted_publisher = current_user.oidc_pending_trusted_publishers.new(
create_params.merge(
expires_at: 12.hours.from_now
)
)

if trusted_publisher.save
redirect_to profile_oidc_pending_trusted_publishers_path, flash: { notice: t(".success") }
else
flash.now[:error] = trusted_publisher.errors.full_messages.to_sentence
render OIDC::PendingTrustedPublishers::NewView.new(
pending_trusted_publisher: trusted_publisher
), status: :unprocessable_entity
end
end

def destroy
if @pending_trusted_publisher.destroy
redirect_to profile_oidc_pending_trusted_publishers_path, flash: { notice: t(".success") }
else
redirect_back fallback_location: profile_oidc_pending_trusted_publishers_path,

Check warning on line 43 in app/controllers/oidc/pending_trusted_publishers_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/oidc/pending_trusted_publishers_controller.rb#L43

Added line #L43 was not covered by tests
flash: { error: @pending_trusted_publisher.errors.full_messages.to_sentence }
end
end

private

def create_params
params.permit(
create_params_key => [
:rubygem_name,
:trusted_publisher_type,
{ trusted_publisher_attributes: @trusted_publisher_type.permitted_attributes }
]
).require(create_params_key)
end

def create_params_key = :oidc_pending_trusted_publisher

def find_pending_trusted_publisher
@pending_trusted_publisher = current_user.oidc_pending_trusted_publishers.find(params.require(:id))
end
end
80 changes: 80 additions & 0 deletions app/controllers/oidc/rubygem_trusted_publishers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
class OIDC::RubygemTrustedPublishersController < ApplicationController
include OIDC::Concerns::TrustedPublisherCreation

before_action :find_rubygem
before_action :render_forbidden, unless: :owner?
before_action :find_rubygem_trusted_publisher, except: %i[index new create]

def index
render OIDC::RubygemTrustedPublishers::IndexView.new(
rubygem: @rubygem,
trusted_publishers: @rubygem.oidc_rubygem_trusted_publishers.includes(:trusted_publisher).page(@page).strict_loading
)
end

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

def create
trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.new(
create_params
)

if trusted_publisher.save
redirect_to rubygem_trusted_publishers_path(@rubygem.slug), flash: { notice: t(".success") }
else
flash.now[:error] = trusted_publisher.errors.full_messages.to_sentence
render OIDC::RubygemTrustedPublishers::NewView.new(
rubygem_trusted_publisher: trusted_publisher
), status: :unprocessable_entity
end
end

def destroy
if @rubygem_trusted_publisher.destroy
redirect_to rubygem_trusted_publishers_path(@rubygem.slug), flash: { notice: t(".success") }
else
redirect_back fallback_location: rubygem_trusted_publishers_path(@rubygem.slug),

Check warning on line 40 in app/controllers/oidc/rubygem_trusted_publishers_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/oidc/rubygem_trusted_publishers_controller.rb#L40

Added line #L40 was not covered by tests
flash: { error: @rubygem_trusted_publisher.errors.full_messages.to_sentence }
end
end

private

def create_params
params.permit(
create_params_key => [
:trusted_publisher_type,
{ trusted_publisher_attributes: @trusted_publisher_type.permitted_attributes }
]
).require(create_params_key)
end

def create_params_key = :oidc_rubygem_trusted_publisher

def find_rubygem_trusted_publisher
@rubygem_trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.find(params.require(:id))
end

def gh_actions_trusted_publisher
github_params = helpers.github_params(@rubygem)

publisher = OIDC::TrustedPublisher::GitHubAction.new
if github_params
publisher.repository_owner = github_params[:user]
publisher.repository_name = github_params[:repo]
publisher.workflow_filename = workflow_filename(publisher.repository)
end
publisher
end

def workflow_filename(repo)
paths = Octokit.contents(repo, path: ".github/workflows").lazy.select { _1.type == "file" }.map(&:name).grep(/\.ya?ml\z/)
paths.max_by { |path| [path.include?("release"), path.include?("push")].map! { (_1 && 1) || 0 } }
rescue Octokit::NotFound
nil
end
end
13 changes: 13 additions & 0 deletions app/helpers/rubygems_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ def ownership_link(rubygem)
link_to I18n.t("rubygems.aside.links.ownership"), rubygem_owners_path(rubygem.slug), class: "gem__link t-list__item"
end

def rubygem_trusted_publishers_link(rubygem)
link_to t("rubygems.aside.links.trusted_publishers"), rubygem_trusted_publishers_path(rubygem.slug), class: "gem__link t-list__item"
end

def oidc_api_key_role_links(rubygem)
roles = current_user.oidc_api_key_roles.for_rubygem(rubygem)

Expand Down Expand Up @@ -135,6 +139,15 @@ def link_to_user(user)
alt: user.display_handle, title: user.display_handle
end

def link_to_pusher(api_key_owner)
case api_key_owner
when OIDC::TrustedPublisher::GitHubAction
image_tag "github_icon.png", width: 48, height: 48, theme: :light, alt: "GitHub", title: api_key_owner.name
else
raise ArgumentError, "unknown api_key_owner type #{api_key_owner.class}"

Check warning on line 147 in app/helpers/rubygems_helper.rb

View check run for this annotation

Codecov / codecov/patch

app/helpers/rubygems_helper.rb#L147

Added line #L147 was not covered by tests
end
end

def nice_date_for(time)
time.to_date.to_fs(:long)
end
Expand Down
12 changes: 12 additions & 0 deletions app/mailers/mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ def gem_pushed(pushed_by, version_id, notified_user_id)
default: "Gem %{gem} pushed to RubyGems.org")
end

def gem_trusted_publisher_added(rubygem_trusted_publisher, created_by_user, notified_user)
@rubygem_trusted_publisher = rubygem_trusted_publisher
@created_by_user = created_by_user
@notified_user = notified_user

mail to: notified_user.email,
subject: I18n.t("mailer.gem_trusted_publisher_added.subject",
gem: @rubygem_trusted_publisher.rubygem.name,
host: Gemcutter::HOST_DISPLAY,
default: "Trusted publisher added to %{gem} on RubyGems.org")
end

def mfa_notification(user_id)
@user = User.find(user_id)

Expand Down
2 changes: 1 addition & 1 deletion app/models/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ApiKey < ApplicationRecord
has_one :api_key_rubygem_scope, dependent: :destroy
has_one :ownership, through: :api_key_rubygem_scope
has_one :oidc_id_token, class_name: "OIDC::IdToken", dependent: :restrict_with_error
has_one :oidc_api_key_role, through: :oidc_id_token, source: :api_key_role, inverse_of: :api_keys
has_one :oidc_api_key_role, class_name: "OIDC::ApiKeyRole", through: :oidc_id_token, source: :api_key_role, inverse_of: :api_keys
has_many :pushed_versions, class_name: "Version", inverse_of: :pusher_api_key, foreign_key: :pusher_api_key_id, dependent: :nullify

before_validation :set_owner_from_user
Expand Down

0 comments on commit 89a8202

Please sign in to comment.