Skip to content

Commit

Permalink
Add trusted publisher API (rubygems#4690)
Browse files Browse the repository at this point in the history
Signed-off-by: Samuel Giddins <segiddins@segiddins.me>
  • Loading branch information
segiddins committed May 8, 2024
1 parent d3ed765 commit eaf9bef
Show file tree
Hide file tree
Showing 16 changed files with 290 additions and 2 deletions.
4 changes: 4 additions & 0 deletions app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,8 @@ def render_bad_request(error = "bad request")
error = error.message if error.is_a?(Exception)
render json: { error: error.to_s }, status: :bad_request
end

def owner?
@api_key.owner.owns_gem?(@rubygem)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
class Api::V1::OIDC::RubygemTrustedPublishersController < Api::BaseController
before_action :authenticate_with_api_key
before_action :verify_user_api_key

before_action :find_rubygem

before_action :verify_api_key_gem_scope
before_action :verify_with_otp
before_action :verify_mfa_requirement
before_action :verify_api_key_scope

before_action :render_forbidden, unless: :owner?
before_action :find_rubygem_trusted_publisher, except: %i[index create]
before_action :set_trusted_publisher_type, only: %i[create]

def index
render json: @rubygem.oidc_rubygem_trusted_publishers.strict_loading
.includes(:trusted_publisher)
end

def show
render json: @rubygem_trusted_publisher
end

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

render json: trusted_publisher, status: :created
end

def destroy
@rubygem_trusted_publisher.destroy!
end

private

def verify_api_key_scope
render_api_key_forbidden unless @api_key.can_configure_trusted_publishers?
end

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

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

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

return if @trusted_publisher_type

render json: { error: t("oidc.trusted_publisher.unsupported_type") }, status: :unprocessable_entity
end

def create_params
create_params = params.permit(
:trusted_publisher_type,
trusted_publisher: @trusted_publisher_type.permitted_attributes
)
create_params[:trusted_publisher_attributes] = create_params.delete(:trusted_publisher)
create_params
end
end
6 changes: 4 additions & 2 deletions app/models/api_key.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class ApiKey < ApplicationRecord
API_SCOPES = %i[show_dashboard index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks].freeze
APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner remove_owner].freeze
API_SCOPES = %i[show_dashboard index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks
configure_trusted_publishers].freeze
APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner remove_owner configure_trusted_publishers].freeze
EXCLUSIVE_SCOPES = %i[show_dashboard].freeze

belongs_to :owner, polymorphic: true
Expand Down Expand Up @@ -151,6 +152,7 @@ def set_owner_from_user
def set_legacy_scope_columns
scopes = self.scopes
API_SCOPES.each do |scope|
next if scope == :configure_trusted_publishers
self[scope] = scopes.include?(scope)
end
end
Expand Down
10 changes: 10 additions & 0 deletions app/models/oidc/rubygem_trusted_publisher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,14 @@ class OIDC::RubygemTrustedPublisher < ApplicationRecord
def build_trusted_publisher(params)
self.trusted_publisher = trusted_publisher_type.constantize.build_trusted_publisher(params)
end

def payload
{
id:,
trusted_publisher_type:,
trusted_publisher: trusted_publisher
}
end

delegate :as_json, to: :payload
end
13 changes: 13 additions & 0 deletions app/models/oidc/trusted_publisher/github_action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ def self.build_trusted_publisher(params)

def self.publisher_name = "GitHub Actions"

def payload
{
name:,
repository_owner:,
repository_name:,
repository_owner_id:,
workflow_filename:,
environment:
}
end

delegate :as_json, to: :payload

def repository_condition
OIDC::AccessPolicy::Statement::Condition.new(
operator: "string_equals",
Expand Down
1 change: 1 addition & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ de:
remove_owner: Besitzer entfernen
access_webhooks: Webhooks zugreifen
show_dashboard: Dashboard anzeigen
configure_trusted_publishers:
reset: Zurücksetzen
save_key: 'Beachten Sie, dass wir Ihnen den Schlüssel nicht erneut anzeigen
können. Neuer API-Schlüssel:'
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ en:
remove_owner: Remove owner
access_webhooks: Access webhooks
show_dashboard: Show dashboard
configure_trusted_publishers: Configure trusted publishers
reset: Reset
save_key: "Note that we won't be able to show the key to you again. New API key:"
mfa: MFA
Expand Down
1 change: 1 addition & 0 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ es:
remove_owner: Eliminar propietario
access_webhooks: Acceso a webhooks
show_dashboard: Mostrar dashboard
configure_trusted_publishers:
reset: Restablecer
save_key: 'Ten en cuenta que no se volverá a mostrar la clave de API. Nueva
clave de API:'
Expand Down
1 change: 1 addition & 0 deletions config/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ fr:
remove_owner:
access_webhooks:
show_dashboard:
configure_trusted_publishers:
reset:
save_key:
mfa:
Expand Down
1 change: 1 addition & 0 deletions config/locales/ja.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ ja:
remove_owner: 所有者を削除する
access_webhooks: webhookにアクセスする
show_dashboard: ダッシュボードを表示
configure_trusted_publishers:
reset: リセット
save_key: キーを二度と表示できなくなるためご注意ください。新しいAPIキー:
mfa: MFA
Expand Down
1 change: 1 addition & 0 deletions config/locales/nl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ nl:
remove_owner:
access_webhooks:
show_dashboard:
configure_trusted_publishers:
reset:
save_key:
mfa:
Expand Down
1 change: 1 addition & 0 deletions config/locales/pt-BR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ pt-BR:
remove_owner:
access_webhooks:
show_dashboard:
configure_trusted_publishers:
reset:
save_key:
mfa:
Expand Down
1 change: 1 addition & 0 deletions config/locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ zh-CN:
remove_owner: 移除(某个)Gem 业主
access_webhooks: 访问后 (Access) Webhook
show_dashboard: 显示仪表盘
configure_trusted_publishers:
reset: 重置
save_key: 请注意在此之后我们不会再次向您显示该密钥。新的 API 密钥为:
mfa: 多因素验证
Expand Down
1 change: 1 addition & 0 deletions config/locales/zh-TW.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ zh-TW:
remove_owner:
access_webhooks:
show_dashboard:
configure_trusted_publishers:
reset:
save_key:
mfa:
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
end
constraints rubygem_id: Patterns::ROUTE_PATTERN do
resource :owners, only: %i[show create destroy]
resources :trusted_publishers, controller: 'oidc/rubygem_trusted_publishers', only: %i[index create destroy show]
end
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
require "test_helper"

class Api::V1::OIDC::RubygemTrustedPublishersControllerTest < ActionDispatch::IntegrationTest
make_my_diffs_pretty!

setup do
create(:oidc_provider, issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER)
end

context "without an API key" do
context "on GET to index" do
setup do
get api_v1_rubygem_trusted_publishers_path("rails")
end

should "deny access" do
assert_response :unauthorized
end
end

context "on GET to show" do
setup do
get api_v1_rubygem_trusted_publisher_path("rails", 0)
end

should "deny access" do
assert_response :unauthorized
end
end

context "on POST to create" do
setup do
post api_v1_rubygem_trusted_publishers_path("rails"),
params: {}
end

should "deny access" do
assert_response :unauthorized
end
end

context "on DELETE to destory" do
setup do
delete api_v1_rubygem_trusted_publisher_path("rails", 0),
params: {}
end

should "deny access" do
assert_response :unauthorized
end
end
end

context "on GET to show without configure_trusted_publishers scope" do
setup do
@api_key = create(:api_key, key: "12345", scopes: %i[push_rubygem])
@rubygem = create(:rubygem, owners: [@api_key.owner])

get api_v1_rubygem_trusted_publisher_path(@rubygem.slug, 2),
headers: { "HTTP_AUTHORIZATION" => "12345" }
end

should "deny access" do
assert_response :forbidden
assert_match "The API key doesn't have access", @response.body
end
end

context "with an authorized API key" do
setup do
@api_key = create(:api_key, key: "12345", scopes: %i[configure_trusted_publishers])
@rubygem = create(:rubygem, owners: [@api_key.owner], indexed: true)
end

context "on GET to index" do
setup do
get api_v1_rubygem_trusted_publishers_path(@rubygem.slug),
headers: { "HTTP_AUTHORIZATION" => "12345" }
end

should "return all trusted publishers" do
assert_response :success
end

context "with a trusted publisher" do
setup do
create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem)
get api_v1_rubygem_trusted_publishers_path(@rubygem.slug),
headers: { "HTTP_AUTHORIZATION" => "12345" }
end

should "return the trusted publisher" do
assert_response :success
assert_equal 1, @response.parsed_body.size
end
end
end

context "on GET to show" do
setup do
@trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem)
get api_v1_rubygem_trusted_publisher_path(@rubygem.slug, @trusted_publisher.id),
headers: { "HTTP_AUTHORIZATION" => "12345" }
end

should "return the trusted publisher" do
repository_name = @trusted_publisher.trusted_publisher.repository_name

assert_response :success
assert_equal(
{ "id" => @trusted_publisher.id,
"trusted_publisher_type" => "OIDC::TrustedPublisher::GitHubAction",
"trusted_publisher" => {
"name" => "GitHub Actions example/#{repository_name} @ .github/workflows/push_gem.yml",
"repository_owner" => "example",
"repository_name" => repository_name,
"repository_owner_id" => "123456",
"workflow_filename" => "push_gem.yml",
"environment" => nil
} }, @response.parsed_body
)
end
end

context "on POST to create" do
should "create a trusted publisher" do
stub_request(:get, "https://api.github.com/users/example")
.to_return(status: 200, body: { id: "123456" }.to_json, headers: { "Content-Type" => "application/json" })

post api_v1_rubygem_trusted_publishers_path(@rubygem.slug),
params: {
trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: {
repository_owner: "example",
repository_name: "rubygem1",
workflow_filename: "push_gem.yml"
}
},
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :created
trusted_publisher = OIDC::RubygemTrustedPublisher.find(response.parsed_body["id"])

assert_equal @rubygem, trusted_publisher.rubygem
assert_equal(
{ "id" => response.parsed_body["id"],
"trusted_publisher_type" => "OIDC::TrustedPublisher::GitHubAction",
"trusted_publisher" => {
"name" => "GitHub Actions example/rubygem1 @ .github/workflows/push_gem.yml",
"repository_owner" => "example",
"repository_name" => "rubygem1",
"repository_owner_id" => "123456",
"workflow_filename" => "push_gem.yml",
"environment" => nil
} }, response.parsed_body
)
end

should "error creating trusted publisher with unknown type" do
post api_v1_rubygem_trusted_publishers_path(@rubygem.slug),
params: {
trusted_publisher_type: "Hash",
trusted_publisher: { repository_owner: "example" }
},
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :unprocessable_entity
assert_equal "Unsupported trusted publisher type", response.parsed_body["error"]
end
end

context "on DELETE to destroy" do
should "destroy the trusted publisher" do
trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem)

delete api_v1_rubygem_trusted_publisher_path(@rubygem.slug, trusted_publisher.id),
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :no_content
assert OIDC::RubygemTrustedPublisher.none?(id: trusted_publisher.id)
end
end
end
end

0 comments on commit eaf9bef

Please sign in to comment.