Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

class Api::V1::OIDC::PendingTrustedPublishersController < Api::BaseController
before_action :authenticate_with_api_key
before_action :verify_user_api_key
before_action :verify_with_otp
before_action :set_trusted_publisher_type, only: %i[create]

def index
Comment thread
colby-swandale marked this conversation as resolved.
authorize OIDC::PendingTrustedPublisher, :index?
pending = policy_scope(OIDC::PendingTrustedPublisher)
.unexpired.includes(:trusted_publisher).strict_loading
render json: pending
end

def create
pending = authorize @api_key.user.oidc_pending_trusted_publishers.build(
create_params.merge(expires_at: 12.hours.from_now)
)

if pending.save
render json: pending, status: :created
else
render json: { errors: pending.errors, status: :unprocessable_content }, status: :unprocessable_content
end
end

private

def set_trusted_publisher_type
trusted_publisher_type = params.expect(: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_content
end

def create_params
create_params = params.permit(
:rubygem_name,
:trusted_publisher_type,
trusted_publisher: @trusted_publisher_type.permitted_attributes
)
create_params[:trusted_publisher_attributes] = create_params.delete(:trusted_publisher)
create_params
end
end
12 changes: 12 additions & 0 deletions app/models/oidc/pending_trusted_publisher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ def build_trusted_publisher(params)
self.trusted_publisher = trusted_publisher_type.constantize.build_trusted_publisher(params)
end

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

delegate :as_json, to: :payload

private

def available_rubygem_name
Expand Down
36 changes: 36 additions & 0 deletions app/policies/api/oidc/pending_trusted_publisher_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

class Api::OIDC::PendingTrustedPublisherPolicy < Api::ApplicationPolicy
class Scope < Api::ApplicationPolicy::Scope
def initialize(api_key, scope) # rubocop:disable Lint/MissingSuper
@api_key = api_key
@scope = scope
end

def resolve
scope.where(user: api_key.user)
end
end

def index?
user_api_key? &&
account_scoped_key? &&
api_key_scope?(:configure_trusted_publishers)
end

def create?
user_api_key? &&
mfa_requirement_satisfied? &&
account_scoped_key? &&
api_key_scope?(:configure_trusted_publishers)
end

private

# Pending publishers are for not-yet-existing gems, so a gem-restricted key
# has no meaningful gem to bind to and must not register arbitrary names.
def account_scoped_key?
return true unless api_key.rubygem
deny t(:api_key_insufficient_scope)
end
end
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@
resources :providers, only: %i[index show], format: 'json', defaults: { format: :json }

resources :id_tokens, only: %i[index show], format: 'json', defaults: { format: :json }

resources :pending_trusted_publishers, only: %i[index create], format: 'json', defaults: { format: :json }
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# frozen_string_literal: true

require "test_helper"

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

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

context "without an API key" do
should "deny index" do
get api_v1_oidc_pending_trusted_publishers_path

assert_response :unauthorized
end

should "deny create" do
post api_v1_oidc_pending_trusted_publishers_path, params: {}

assert_response :unauthorized
end
end

context "with an account-scoped configure_trusted_publishers key" do
setup do
@api_key = create(:api_key, key: "12345", scopes: %i[configure_trusted_publishers])
end

context "on POST to create" do
should "create a pending 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" })

assert_difference -> { @api_key.user.oidc_pending_trusted_publishers.count }, 1 do
post api_v1_oidc_pending_trusted_publishers_path,
params: {
rubygem_name: "brand-new-gem",
trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: {
repository_owner: "example",
repository_name: "brand-new-gem",
workflow_filename: "push_gem.yml"
}
},
headers: { "HTTP_AUTHORIZATION" => "12345" }
end

assert_response :created
pending = OIDC::PendingTrustedPublisher.find(response.parsed_body["id"])

assert_equal "brand-new-gem", pending.rubygem_name
assert_equal @api_key.user, pending.user
assert_equal "example", pending.trusted_publisher.repository_owner

# Contract the CLI depends on: nested trusted_publisher object in the response body.
assert_predicate response.parsed_body["id"], :present?
assert_equal "brand-new-gem", response.parsed_body["rubygem_name"]
nested = response.parsed_body["trusted_publisher"]

assert_kind_of Hash, nested
assert_equal "example", nested["repository_owner"]
assert_equal "brand-new-gem", nested["repository_name"]
assert_equal "push_gem.yml", nested["workflow_filename"]
end
end

context "on POST to create with an existing pushable gem name" do
should "return 422" do
stub_request(:get, "https://api.github.com/users/example")
.to_return(status: 200, body: { id: "123456" }.to_json, headers: { "Content-Type" => "application/json" })
rubygem = create(:rubygem, name: "already-exists")
create(:version, rubygem: rubygem)
post api_v1_oidc_pending_trusted_publishers_path,
params: { rubygem_name: "already-exists", trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: { repository_owner: "example", repository_name: "already-exists", workflow_filename: "push_gem.yml" } },
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :unprocessable_content
assert_includes response.parsed_body["errors"].keys, "rubygem_name"
end
end

context "on POST to create with an unsupported type" do
should "return 422" do
post api_v1_oidc_pending_trusted_publishers_path,
params: { rubygem_name: "x", trusted_publisher_type: "Hash", trusted_publisher: { repository_owner: "example" } },
headers: { "HTTP_AUTHORIZATION" => "12345" }

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

context "on POST to create with invalid config" do
should "return 422 with errors" 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_oidc_pending_trusted_publishers_path,
params: { rubygem_name: "x", trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: { repository_owner: "example" } },
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :unprocessable_content
assert_predicate response.parsed_body["errors"], :present?
end
end

context "on GET to index" do
should "return only the calling user's unexpired pending publishers" do
mine = create(:oidc_pending_trusted_publisher, user: @api_key.user)
create(:oidc_pending_trusted_publisher) # another user
create(:oidc_pending_trusted_publisher, user: @api_key.user, expires_at: 1.hour.ago) # expired

get api_v1_oidc_pending_trusted_publishers_path, headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :success
assert_equal([mine.id], response.parsed_body.pluck("id"))

# Contract the CLI depends on: each item carries a nested trusted_publisher object.
response.parsed_body.each do |item|
assert_kind_of Hash, item["trusted_publisher"]
assert_predicate item["trusted_publisher"]["repository_owner"], :present?
end
end
end
end

context "with a key lacking configure_trusted_publishers scope" do
setup do
@api_key = create(:api_key, key: "12345", scopes: %i[push_rubygem])
end

should "deny create" do
post api_v1_oidc_pending_trusted_publishers_path,
params: { rubygem_name: "x", trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: { repository_owner: "example", repository_name: "x", workflow_filename: "push_gem.yml" } },
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :forbidden
end
end

context "with a gem-scoped configure_trusted_publishers key" do
setup do
@owner = create(:user)
@rubygem = create(:rubygem, owners: [@owner])
@api_key = create(:api_key, key: "12345", scopes: %i[configure_trusted_publishers],
owner: @owner, rubygem: @rubygem)
end

should "deny create (gem-scoped keys cannot register pending publishers)" do
post api_v1_oidc_pending_trusted_publishers_path,
params: { rubygem_name: "x", trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: { repository_owner: "example", repository_name: "x", workflow_filename: "push_gem.yml" } },
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :forbidden
assert_includes @response.body, "This API key cannot perform the specified action on this gem."
end
end

context "with a trusted-publisher-owned (non-user) key" do
setup do
@api_key = create(:api_key, :trusted_publisher, key: "12345", scopes: %i[configure_trusted_publishers])
end

should "deny create" do
post api_v1_oidc_pending_trusted_publishers_path,
params: { rubygem_name: "x", trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: { repository_owner: "example", repository_name: "x", workflow_filename: "push_gem.yml" } },
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :forbidden
end
end

context "with MFA (ui_and_api) required on the key's user" do
setup do
@user = create(:user, :mfa_enabled, totp_seed: ROTP::Base32.random_base32)
@api_key = create(:api_key, key: "12345", scopes: %i[configure_trusted_publishers], owner: @user)
end

should "reject create without OTP" do
post api_v1_oidc_pending_trusted_publishers_path,
params: { rubygem_name: "x", trusted_publisher_type: "OIDC::TrustedPublisher::GitHubAction",
trusted_publisher: { repository_owner: "example", repository_name: "x", workflow_filename: "push_gem.yml" } },
headers: { "HTTP_AUTHORIZATION" => "12345" }

assert_response :unauthorized
end
end
end
Loading