diff --git a/.rubocop.yml b/.rubocop.yml index 0f22685992c..6b8ed5f560c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -51,6 +51,11 @@ Rails/UniqueValidationWithoutIndex: Rails/ActionControllerTestCase: Enabled: false # Causes every integration test to fail +Rails/Output: + Exclude: + - app/views/**/*_view.rb + - app/views/**/*_component.rb + Layout/ArgumentAlignment: Enabled: false diff --git a/Gemfile b/Gemfile index 06ffa43e883..821c3c5fbfb 100644 --- a/Gemfile +++ b/Gemfile @@ -53,6 +53,7 @@ gem "browser", "~> 5.3", ">= 5.3.1" gem "bcrypt", "~> 3.1", ">= 3.1.18" gem "maintenance_tasks", "~> 2.1" gem "strong_migrations", "~> 1.6" +gem "phlex-rails", "~> 1.0" # Admin dashboard gem "avo", "~> 2.28", "< 2.36" # 2.36+ requires to fix test failures diff --git a/Gemfile.lock b/Gemfile.lock index 29d5d26b0a5..97422fcd0a5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,6 +141,7 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) cbor (0.5.9.6) + cgi (0.3.6) chartkick (5.0.4) choice (0.2.0) chunky_png (1.4.0) @@ -206,6 +207,8 @@ GEM dry-initializer (3.1.1) email_validator (2.2.3) activemodel + erb (4.0.3) + cgi (>= 0.3.3) erubi (1.12.0) et-orbi (1.2.7) tzinfo @@ -443,6 +446,14 @@ GEM parser (3.2.1.1) ast (~> 2.4.1) pg (1.5.4) + phlex (1.8.1) + concurrent-ruby (~> 1.2) + erb (>= 4) + zeitwerk (~> 2.6) + phlex-rails (1.0.0) + phlex (~> 1.7) + rails (>= 6.1, < 8) + zeitwerk (~> 2.6) pry (0.14.1) coderay (~> 1.1) method_source (~> 1.0) @@ -747,6 +758,7 @@ DEPENDENCIES opensearch-dsl (~> 0.2.0) opensearch-ruby (~> 1.0) pg (~> 1.4) + phlex-rails (~> 1.0) pry-byebug (~> 3.10) puma (~> 6.1) pundit (~> 2.3) diff --git a/app/assets/javascripts/oidc_api_key_role_form.js b/app/assets/javascripts/oidc_api_key_role_form.js new file mode 100644 index 00000000000..4257c60fd95 --- /dev/null +++ b/app/assets/javascripts/oidc_api_key_role_form.js @@ -0,0 +1,71 @@ +$(function () { + function wire() { + var removeNestedButtons = $("button.form__remove_nested_button"); + + removeNestedButtons.off("click"); + removeNestedButtons.click(function (e) { + e.preventDefault(); + var button = $(this); + var nestedField = button.closest(".form__nested_fields"); + + nestedField.remove(); + }); + + var addNestedButtons = $("button.form__add_nested_button"); + addNestedButtons.off("click"); + addNestedButtons.click(function (e) { + e.preventDefault(); + var button = $(this); + var nestedFields = button.siblings("template.form__nested_fields"); + + var content = nestedFields + .html() + .replace(/NEW_OBJECT/g, new Date().getTime()); + + $(content).insertAfter(button.siblings().last()); + + wire(); + }); + } + + wire(); + + // var enableGemScopeCheckboxes = $( + // "#push_rubygem, #yank_rubygem, #add_owner, #remove_owner" + // ); + // var hiddenRubygemId = "hidden_api_key_rubygem_id"; + // toggleGemSelector(); + + // enableGemScopeCheckboxes.click(function () { + // toggleGemSelector(); + // }); + + // function toggleGemSelector() { + // var isApplicableGemScopeSelected = enableGemScopeCheckboxes.is(":checked"); + // var gemScopeSelector = $("#api_key_rubygem_id"); + + // if (isApplicableGemScopeSelected) { + // gemScopeSelector.removeAttr("disabled"); + // removeHiddenRubygemField(); + // } else { + // gemScopeSelector.val(""); + // gemScopeSelector.prop("disabled", true); + // addHiddenRubygemField(); + // } + // } + + // function addHiddenRubygemField() { + // $("") + // .attr({ + // type: "hidden", + // id: hiddenRubygemId, + // name: "api_key[rubygem_id]", + // value: "", + // }) + // .appendTo(".t-body form .api_key_rubygem_id_form"); + // } + + // function removeHiddenRubygemField() { + // $("#" + hiddenRubygemId + ":hidden").remove(); + // } +}); diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index 20fb868cc2b..e35045439d5 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -17,6 +17,9 @@ .l-mr-4 { margin-right: 1rem; } +.l-mb-0 { + margin-bottom: 0 !important; } + .l-mb-4 { margin-bottom: 1rem; } diff --git a/app/assets/stylesheets/modules/form.css b/app/assets/stylesheets/modules/form.css index 79514574d82..416e2207ac4 100644 --- a/app/assets/stylesheets/modules/form.css +++ b/app/assets/stylesheets/modules/form.css @@ -41,14 +41,21 @@ .form__input__addon-left .form__input { padding-left: 45px; } -.form__input, .form__textarea, .form__select, .form__group { +.form__nested_fields, .form__input, .form__textarea, .form__select, .form__group { margin-bottom: 30px; } +.form__nested_fields, .form__input, .form__textarea { + display: block; + width: 100%; +} + +.form__nested_fields { + margin: 12px 0 12px 32px; + width: calc(100% - 32px); } + .form__input, .form__textarea { -webkit-appearance: none; padding: 12px 16px; - display: block; - width: 100%; font-weight: 300; font-size: 18px; border: 1px solid #f2f3f4; @@ -219,3 +226,25 @@ .form__checkbox__item .field_with_errors { display: contents; } + +.form__flex_group { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 20px; +} + +.form__flex_group > .form__submit { + margin: initial; + width: initial; + align-self: center; +} + +.form__scope_checkbox_grid_group { + display: grid; + grid-template-columns: repeat(auto-fill, 200px); + grid-gap: 20px; +} diff --git a/app/assets/stylesheets/modules/gem.css b/app/assets/stylesheets/modules/gem.css index b35236d63ac..aeb2c8373c0 100644 --- a/app/assets/stylesheets/modules/gem.css +++ b/app/assets/stylesheets/modules/gem.css @@ -128,6 +128,11 @@ border: none; font-weight: bold; } +.gem__code.multiline { + line-height: inherit; + white-space: pre-wrap; + border-radius: 0; } + .gem__code::-webkit-scrollbar { display: none; } @@ -155,6 +160,25 @@ width: 10px; background-image: linear-gradient(to right, transparent 0%, white 100%); } +.gem__code__header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: .5rem; + border-top-left-radius: .25rem; + border-top-right-radius: .25rem; + border: #c1c4ca 1px solid; + border-bottom: 0; } + +.gem__code__header .gem__code__icon { + position: inherit; + padding: .125rem; + width: 40px; } + +.gem__code__icon.static { + position: static; } + .gem__code__tooltip--copy, .gem__code__tooltip--copied { display: none; } diff --git a/app/assets/stylesheets/modules/oidc.css b/app/assets/stylesheets/modules/oidc.css new file mode 100644 index 00000000000..d3240ef0d5f --- /dev/null +++ b/app/assets/stylesheets/modules/oidc.css @@ -0,0 +1,71 @@ +dl.api_key_permissions { + margin-top: 1em; + display: grid; + grid-template-columns: 1fr 2fr; +} + +dl.api_key_permissions dt { + font-weight: bold; + float: inherit; +} + +dl.oidc_access_policy { + display: grid; + column-gap: 1rem; + grid-template-columns: fit-content(6rem) auto; +} + +dl.provider_attributes { + column-gap: 1rem; + align-items: baseline; + row-gap: 1rem; +} + +@media (max-width: 420px){ + dl.provider_attributes { + display: flex; + flex-direction: column; + } +} +@media (min-width: 421px){ + dl.provider_attributes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +dl.full-width { + width: 100%; + overflow-wrap: break-word; + word-break: break-word; + +} + +dl.provider_attributes dt.text-right { + text-align: right; +} + +dl.provider_attributes dt.text-left { + text-align: left; +} + +dl.provider_attributes dd ul.tag-list { + display: flex; + flex-direction: row; + list-style: none; + justify-content: start; + column-gap: 1rem; + flex-wrap: wrap; + align-items: baseline; + margin: 0; +} + +dl.provider_attributes dd ul.tag-list li { + padding: 0.5rem 1rem; + background-color: #e2e8f0; + flex-shrink: 1; + border-radius: 9999px; +} +dl.provider_attributes dd ul.tag-list li:before { + height: 0; +} diff --git a/app/assets/stylesheets/modules/search.css b/app/assets/stylesheets/modules/search.css index 75b143eaf47..c009b1b9347 100644 --- a/app/assets/stylesheets/modules/search.css +++ b/app/assets/stylesheets/modules/search.css @@ -135,11 +135,11 @@ color: white; } -dl { +dl.search-fields { margin: 6% 2%; } -dt { +dl.search-fields dt { float: left; padding: 11px 0px; color: #585858; @@ -147,12 +147,12 @@ dt { @media (min-width: 520px) { - dd { + dl.search-fields dd { margin-left: 25%; } } -dd input { +dl.search-fields dd input { max-width: none !important;; } diff --git a/app/assets/stylesheets/type.css b/app/assets/stylesheets/type.css index fbd38e0eeba..ecba22f18c1 100644 --- a/app/assets/stylesheets/type.css +++ b/app/assets/stylesheets/type.css @@ -125,6 +125,9 @@ a.t-list__item { font-style: normal; content: "→"; } +.t-underline { + text-decoration: underline; } + .t-body p, .t-body ol li, .t-body ul li { font-weight: 300; font-size: 18px; @@ -175,6 +178,8 @@ a.t-list__item { overflow-x: scroll; line-height: 1.33; word-break: normal; } + .t-body pre code.multiline { + word-spacing: inherit; } .t-body code { font-weight: bold; font-family: "courier", monospace; diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb index 205c385c90f..286b0b10afb 100644 --- a/app/controllers/api_keys_controller.rb +++ b/app/controllers/api_keys_controller.rb @@ -7,7 +7,7 @@ class ApiKeysController < ApplicationController def index @api_key = session.delete(:api_key) - @api_keys = current_user.api_keys.unexpired + @api_keys = current_user.api_keys.unexpired.not_oidc redirect_to new_profile_api_key_path if @api_keys.empty? end diff --git a/app/controllers/oidc/api_key_roles_controller.rb b/app/controllers/oidc/api_key_roles_controller.rb new file mode 100644 index 00000000000..4f652a0fb0b --- /dev/null +++ b/app/controllers/oidc/api_key_roles_controller.rb @@ -0,0 +1,116 @@ +class OIDC::ApiKeyRolesController < ApplicationController + include ApiKeyable + + helper RubygemsHelper + + before_action :redirect_to_signin, unless: :signed_in? + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? + before_action :redirect_to_verify, unless: :password_session_active? + before_action :find_api_key_role, except: %i[index new create] + before_action :set_page, only: :index + + def index + @api_key_roles = current_user.oidc_api_key_roles.includes(:provider) + .page(@page) + .strict_loading + end + + def show + @id_tokens = @api_key_role.id_tokens.order(id: :desc).includes(:api_key) + .page(0).per(10) + .strict_loading + respond_to do |format| + format.json do + render json: @api_key_role + end + format.html + end + end + + def github_actions_workflow + render OIDC::ApiKeyRoles::GitHubActionsWorkflowView.new(api_key_role: @api_key_role) + end + + def new + rubygem = Rubygem.find_by(name: params[:rubygem]) + scopes = params.permit(scopes: []).fetch(:scopes, []) + @api_key_role = current_user.oidc_api_key_roles.build + @api_key_role.name = "Push #{rubygem.name}" if rubygem + @api_key_role.api_key_permissions = OIDC::ApiKeyPermissions.new( + gems: rubygem ? [rubygem.name] : [], + scopes: scopes + ) + + condition = OIDC::AccessPolicy::Statement::Condition.new + statement = OIDC::AccessPolicy::Statement.new(conditions: [condition]) + add_default_params(rubygem, statement, condition) + + @api_key_role.access_policy = OIDC::AccessPolicy.new(statements: [statement]) + end + + def edit + end + + def create + @api_key_role = current_user.oidc_api_key_roles.build(api_key_role_params) + if @api_key_role.save + redirect_to profile_oidc_api_key_role_path(@api_key_role.token), flash: { notice: t(".success") } + else + flash.now[:error] = @api_key_role.errors.full_messages.to_sentence + render :new + end + end + + def update + if @api_key_role.update(api_key_role_params) + redirect_to profile_oidc_api_key_role_path(@api_key_role.token), flash: { notice: t(".success") } + else + flash.now[:error] = @api_key_role.errors.full_messages.to_sentence + render :edit + end + end + + private + + def find_api_key_role + @api_key_role = current_user.oidc_api_key_roles + .includes(:provider) + .find_by!(token: params.require(:token)) + end + + def redirect_to_verify + session[:redirect_uri] = request.path_info + redirect_to verify_session_path + end + + def api_key_role_params + params.require(:oidc_api_key_role).permit( + :name, :oidc_provider_id, + api_key_permissions: [{ scopes: [] }, :valid_for, { gems: [] }], + access_policy: { + statements_attributes: [:effect, { principal: :oidc }, + { conditions_attributes: %i[operator claim value] }] + } + ) + end + + def add_default_params(rubygem, statement, condition) + condition.claim = "aud" + condition.operator = "string_equals" + condition.value = Gemcutter::HOST + + return unless rubygem + return unless (gh = helpers.link_to_github(rubygem)).presence + return unless (@api_key_role.provider = OIDC::Provider.github_actions) + + statement.principal = { oidc: @api_key_role.provider.issuer } + + repo_condition = OIDC::AccessPolicy::Statement::Condition.new( + claim: "repository", + operator: "string_equals", + value: gh.path.split("/")[1, 2].join("/") + ) + statement.conditions << repo_condition + end +end diff --git a/app/controllers/oidc/id_tokens_controller.rb b/app/controllers/oidc/id_tokens_controller.rb new file mode 100644 index 00000000000..c5e98a8a372 --- /dev/null +++ b/app/controllers/oidc/id_tokens_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class OIDC::IdTokensController < ApplicationController + include ApiKeyable + + before_action :redirect_to_signin, unless: :signed_in? + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? + before_action :redirect_to_verify, unless: :password_session_active? + before_action :find_id_token, except: %i[index] + before_action :set_page, only: :index + + def index + id_tokens = current_user.oidc_id_tokens.includes(:api_key, :api_key_role, :provider) + .page(@page) + .strict_loading + render OIDC::IdTokens::IndexView.new(id_tokens:) + end + + def show + render OIDC::IdTokens::ShowView.new(id_token: @id_token) + end + + private + + def find_id_token + @id_token = current_user.oidc_id_tokens.find(params.require(:id)) + end + + def redirect_to_verify + session[:redirect_uri] = request.path_info + redirect_to verify_session_path + end +end diff --git a/app/controllers/oidc/providers_controller.rb b/app/controllers/oidc/providers_controller.rb new file mode 100644 index 00000000000..d8eef9deb80 --- /dev/null +++ b/app/controllers/oidc/providers_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class OIDC::ProvidersController < ApplicationController + before_action :redirect_to_signin, unless: :signed_in? + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? + before_action :redirect_to_verify, unless: :password_session_active? + before_action :find_provider, except: %i[index] + before_action :set_page, only: :index + + def index + providers = OIDC::Provider.all.strict_loading.page(@page) + render OIDC::Providers::IndexView.new(providers:) + end + + def show + render OIDC::Providers::ShowView.new(provider: @provider) + end + + private + + def find_provider + @provider = OIDC::Provider.find(params.require(:id)) + end + + def redirect_to_verify + session[:redirect_uri] = request.path_info + redirect_to verify_session_path + end +end diff --git a/app/helpers/duration_helper.rb b/app/helpers/duration_helper.rb new file mode 100644 index 00000000000..c341476ecfb --- /dev/null +++ b/app/helpers/duration_helper.rb @@ -0,0 +1,10 @@ +module DurationHelper + def duration_string(duration) + parts = duration.parts + parts = { seconds: duration.value } if parts.empty? + + to_sentence(parts + .sort_by { |unit, _| ActiveSupport::Duration::PARTS.index(unit) } + .map { |unit, val| t("duration.#{unit}", count: val) }) + end +end diff --git a/app/helpers/oidc/api_key_roles_helper.rb b/app/helpers/oidc/api_key_roles_helper.rb new file mode 100644 index 00000000000..4af601f03db --- /dev/null +++ b/app/helpers/oidc/api_key_roles_helper.rb @@ -0,0 +1,2 @@ +module OIDC::ApiKeyRolesHelper +end diff --git a/app/helpers/oidc/providers_helper.rb b/app/helpers/oidc/providers_helper.rb new file mode 100644 index 00000000000..ade07a70c24 --- /dev/null +++ b/app/helpers/oidc/providers_helper.rb @@ -0,0 +1,2 @@ +module OIDC::ProvidersHelper +end diff --git a/app/helpers/rubygems_helper.rb b/app/helpers/rubygems_helper.rb index ccb379affea..163f14a5d1a 100644 --- a/app/helpers/rubygems_helper.rb +++ b/app/helpers/rubygems_helper.rb @@ -91,6 +91,25 @@ 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 oidc_api_key_role_links(rubygem) + roles = current_user.oidc_api_key_roles.for_rubygem(rubygem) + + links = roles.map do |role| + link_to( + t("rubygems.aside.links.oidc.api_key_role.name", name: role.name), + profile_oidc_api_key_role_path(role.token), + class: "gem__link t-list__item" + ) + end + links << link_to( + t("rubygems.aside.links.oidc.api_key_role.new"), + new_profile_oidc_api_key_role_path(rubygem: rubygem.name, scopes: ["push_rubygem"]), + class: "gem__link t-list__item" + ) + + safe_join(links) + end + def resend_owner_confirmation_link(rubygem) link_to I18n.t("rubygems.aside.links.resend_ownership_confirmation"), resend_confirmation_rubygem_owners_path(rubygem.slug), class: "gem__link t-list__item" diff --git a/app/models/oidc/access_policy.rb b/app/models/oidc/access_policy.rb index 994eb6208e4..0ab65a4d78d 100644 --- a/app/models/oidc/access_policy.rb +++ b/app/models/oidc/access_policy.rb @@ -61,13 +61,21 @@ def value_expected_type? validates :principal, presence: true, nested: true - validates :conditions, nested: true + validates :conditions, nested: true, presence: true + + def conditions_attributes=(attributes) + self.conditions = attributes.map { Condition.new(_2) } + end end attribute :statements, Types::ArrayOf.new(Types::JsonDeserializable.new(Statement)) validates :statements, presence: true, nested: true + def statements_attributes=(attributes) + self.statements = attributes.map { Statement.new(_2) } + end + class AccessError < StandardError end diff --git a/app/models/oidc/api_key_role.rb b/app/models/oidc/api_key_role.rb index 97915987b84..96c1e50809a 100644 --- a/app/models/oidc/api_key_role.rb +++ b/app/models/oidc/api_key_role.rb @@ -6,20 +6,48 @@ class OIDC::ApiKeyRole < ApplicationRecord class_name: "OIDC::IdToken", inverse_of: :api_key_role, foreign_key: :oidc_api_key_role_id, dependent: :nullify has_many :api_keys, through: :id_tokens, inverse_of: :oidc_api_key_role + scope :for_rubygem, lambda { |rubygem| + if rubygem.blank? + where("(api_key_permissions->'gems')::jsonb <> JSONB ?", nil) + else + where("(api_key_permissions->'gems')::jsonb @> ?", %([#{rubygem.name.to_json}])) + end + } + + scope :for_scope, lambda { |scope| + where("(api_key_permissions->'scopes')::jsonb @> ?", %([#{scope.to_json}])) + } + + validates :name, presence: true, length: { maximum: 255 }, uniqueness: { scope: :user_id } + attribute :api_key_permissions, Types::JsonDeserializable.new(OIDC::ApiKeyPermissions) validates :api_key_permissions, presence: true, nested: true validate :gems_belong_to_user + def github_actions_push? + provider.github_actions? && api_key_permissions.scopes.include?("push_rubygem") + end + def gems_belong_to_user Array.wrap(api_key_permissions&.gems).each_with_index do |name, idx| errors.add("api_key_permissions.gems[#{idx}]", "(#{name}) does not belong to user #{user.display_handle}") if user.rubygems.where(name:).empty? end end + before_validation :set_statement_principals attribute :access_policy, Types::JsonDeserializable.new(OIDC::AccessPolicy) validates :access_policy, presence: true, nested: true validate :all_condition_claims_are_known + # Since the only current value of this is the provider's issuer, we can set it automatically. + def set_statement_principals + return unless provider + access_policy&.statements&.each do |statement| + next if statement.principal.oidc.present? + statement.principal.oidc = provider.issuer + end + end + def all_condition_claims_are_known return unless provider known_claims = provider.configuration.claims_supported diff --git a/app/models/oidc/provider.rb b/app/models/oidc/provider.rb index f9380f794c4..52cc1a6e942 100644 --- a/app/models/oidc/provider.rb +++ b/app/models/oidc/provider.rb @@ -11,6 +11,16 @@ class OIDC::Provider < ApplicationRecord has_many :audits, as: :auditable, dependent: :nullify + GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com".freeze + + def self.github_actions + find_by(issuer: GITHUB_ACTIONS_ISSUER) + end + + def github_actions? + issuer == GITHUB_ACTIONS_ISSUER + end + class Configuration < ::OpenIDConnect::Discovery::Provider::Config::Response attr_optional required_attributes.delete(:authorization_endpoint) diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb index e64fea53d2b..0846fe8f622 100644 --- a/app/views/api_keys/index.html.erb +++ b/app/views/api_keys/index.html.erb @@ -107,4 +107,7 @@

<%= button_to t(".new_key"), new_profile_api_key_path, method: "get", class: "form__submit" %>

+ <% if current_user.oidc_api_key_roles.any? %> +

<%= button_to t("oidc.api_key_roles.index.api_key_roles"), profile_oidc_api_key_roles_path, method: "get", class: "form__submit" %>

+ <% end %> diff --git a/app/views/application_view.rb b/app/views/application_view.rb new file mode 100644 index 00000000000..a46f657d1bd --- /dev/null +++ b/app/views/application_view.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ApplicationView < ApplicationComponent + # The ApplicationView is an abstract class for all your views. + + # By default, it inherits from `ApplicationComponent`, but you + # can change that to `Phlex::HTML` if you want to keep views and + # components independent. + + def title=(title) + @_view_context.instance_variable_set :@title, title + end +end diff --git a/app/views/components/application_component.rb b/app/views/components/application_component.rb new file mode 100644 index 00000000000..e250dd3a109 --- /dev/null +++ b/app/views/components/application_component.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ApplicationComponent < Phlex::HTML + include Phlex::Rails::Helpers::Routes + include ActionView::Helpers::TranslationHelper + + def self.translation_path + @translation_path ||= name&.dup.tap do |n| + n.gsub!(/(::[^:]+)View/, '\1') + n.gsub!("::", ".") + n.gsub!(/([a-z])([A-Z])/, '\1_\2') + n.downcase! + end + end + + if Rails.env.development? + def before_template + comment { "Before #{self.class.name}" } + super + end + end + + private + + def scope_key_by_partial(key) + return key unless key&.start_with?(".") + + "#{self.class.translation_path}#{key}" + end +end diff --git a/app/views/components/oidc/api_key_role/table_component.rb b/app/views/components/oidc/api_key_role/table_component.rb new file mode 100644 index 00000000000..e7e0b037a13 --- /dev/null +++ b/app/views/components/oidc/api_key_role/table_component.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class OIDC::ApiKeyRole::TableComponent < ApplicationComponent + include Phlex::Rails::Helpers::LinkTo + + attr_reader :api_key_roles + + def initialize(api_key_roles:) + @api_key_roles = api_key_roles + super() + end + + def template + table(class: "t-body") do + thead do + tr(class: "owners__row owners__header") do + header { OIDC::ApiKeyRole.human_attribute_name(:name) } + header { OIDC::ApiKeyRole.human_attribute_name(:token) } + header { OIDC::ApiKeyRole.human_attribute_name(:issuer) } + end + end + + tbody(class: "t-body") do + api_key_roles.each do |api_key_role| + tr(class: "owners__row") do + cell(title: "Name") { link_to api_key_role.name, profile_oidc_api_key_role_path(api_key_role.token) } + cell(title: "Role Token") { code { api_key_role.token } } + cell(title: "Provider") { link_to api_key_role.provider.issuer, api_key_role.provider.issuer } + end + end + end + end + end + + private + + def header(&) + th(class: "owners_cell", &) + end + + def cell(title:, &) + td(class: "owners__cell", data: { title: }, &) + end +end diff --git a/app/views/components/oidc/id_token/key_value_pairs_component.rb b/app/views/components/oidc/id_token/key_value_pairs_component.rb new file mode 100644 index 00000000000..3c968740fe4 --- /dev/null +++ b/app/views/components/oidc/id_token/key_value_pairs_component.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class OIDC::IdToken::KeyValuePairsComponent < ApplicationComponent + attr_reader :pairs + + def initialize(pairs:) + @pairs = pairs + super() + end + + def template + dl(class: "t-body provider_attributes full-width overflow-wrap") do + pairs.each do |key, val| + dt(class: "adoption__heading text-right") { code { key } } + dd { code { val } } + end + end + end +end diff --git a/app/views/components/oidc/id_token/table_component.rb b/app/views/components/oidc/id_token/table_component.rb new file mode 100644 index 00000000000..c70dc685062 --- /dev/null +++ b/app/views/components/oidc/id_token/table_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class OIDC::IdToken::TableComponent < ApplicationComponent + extend Dry::Initializer + option :id_tokens + + include Phlex::Rails::Helpers::TimeTag + include Phlex::Rails::Helpers::LinkTo + + def template + table(class: "owners__table") do + thead do + tr(class: "owners__row owners__header") do + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:created_at) } + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:expires_at) } + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:api_key_role) } + th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:jti) } + end + end + + tbody(class: "t-body") do + id_tokens.each do |token| + row(token) + end + end + end + end + + private + + def row(token) + tr(**classes("owners__row", -> { token.api_key.expired? } => "owners__row__invalid")) do + td(class: "owners__cell") { time_tag token.created_at } + td(class: "owners__cell") { time_tag token.api_key.expires_at } + td(class: "owners__cell") { link_to token.api_key_role.name, profile_oidc_api_key_role_path(token.api_key_role.token) } + td(class: "owners__cell") { link_to token.jti, profile_oidc_id_token_path(token), class: "recovery-code-list__item" } + end + end +end diff --git a/app/views/mailer/api_key_created.html.erb b/app/views/mailer/api_key_created.html.erb index 05de1fe464f..6e711be7434 100644 --- a/app/views/mailer/api_key_created.html.erb +++ b/app/views/mailer/api_key_created.html.erb @@ -18,6 +18,11 @@ Scope: <%= @api_key.enabled_scopes.join(", ") %>
Created at: <%= @api_key.created_at.to_formatted_s(:rfc822) %> + <% if @api_key.oidc_id_token.present? %> +
+ <%= ApiKey.human_attribute_name(:oidc_api_key_role) %>: <%= link_to(@api_key.oidc_api_key_role.name, profile_oidc_api_key_role_path(@api_key.oidc_api_key_role.token), target: :_blank) %> + <% end %> +


<% if @api_key.name == "legacy-key" %> diff --git a/app/views/oidc/access_policies/_access_policy.html.erb b/app/views/oidc/access_policies/_access_policy.html.erb new file mode 100644 index 00000000000..289e3478779 --- /dev/null +++ b/app/views/oidc/access_policies/_access_policy.html.erb @@ -0,0 +1,8 @@ +
+
+
<%= access_policy.class.human_attribute_name :statements %>
+
+ <%= render access_policy.statements %> +
+
+
diff --git a/app/views/oidc/access_policy/statement/conditions/_condition.html.erb b/app/views/oidc/access_policy/statement/conditions/_condition.html.erb new file mode 100644 index 00000000000..6a450ff1c21 --- /dev/null +++ b/app/views/oidc/access_policy/statement/conditions/_condition.html.erb @@ -0,0 +1,3 @@ +
+ <%= condition.claim %> <%= condition.operator %> <%= condition.value %> +
diff --git a/app/views/oidc/access_policy/statement/conditions/_fields.html.erb b/app/views/oidc/access_policy/statement/conditions/_fields.html.erb new file mode 100644 index 00000000000..3990e722607 --- /dev/null +++ b/app/views/oidc/access_policy/statement/conditions/_fields.html.erb @@ -0,0 +1,24 @@ +
+
+
+ <%= f.label :claim, class: "form__label" %> + <%= f.text_field :claim, list: f.field_id(:claims_supported), class: "form__input", autocomplete: :off %> + <%= content_tag(:datalist, id: f.field_id(:claims_supported)) do %> + <% if claims_supported = @api_key_role&.provider&.configuration&.claims_supported.presence %> + <%= options_from_collection_for_select(claims_supported, :to_s, :to_s) %> + <% end %> + <% end %> +
+
+ <%= f.label :operator, class: "form__label" %> +

+ <%= f.collection_select :operator, f.object.class::OPERATORS, :to_s, :titleize, class: "form__input form__select" %> +

+
+ <%= f.label :value, class: "form__label" %> +

+ <%= f.text_field :value, class: "form__input", autocomplete: :off %> +

+ <%= f.button t("oidc.api_key_roles.form.remove_condition"), class: "form__submit form__remove_nested_button" %> +
+
diff --git a/app/views/oidc/access_policy/statements/_fields.html.erb b/app/views/oidc/access_policy/statements/_fields.html.erb new file mode 100644 index 00000000000..eb13201f95a --- /dev/null +++ b/app/views/oidc/access_policy/statements/_fields.html.erb @@ -0,0 +1,32 @@ +
+
+ <%= f.label :effect, class: "form__label" %> +

+ <%= f.collection_select :effect, f.object.class::EFFECTS, :to_s, :to_s, selected: :effect, class: "form__input form__select" %> +

+
+ <%= f.label :principal, class: "form__label" %> + <%= f.fields_for :principal, f.object.principal do |f| %> +
+ <%= f.label :oidc, class: "form__label" %> + <%= f.text_field :oidc, class: "form__input", autocomplete: :off, list: f.field_id(:issuers) %> + <%= content_tag(:datalist, id: f.field_id(:issuers)) do %> + <%= options_from_collection_for_select(OIDC::Provider.limit(50).pluck(:issuer), :to_s, :to_s) %> + <% end %> +
+ <% end %> +
+
+ <%= f.label :conditions, class: "form__label" %> + <%= f.button t("oidc.api_key_role.form.add_condition"), class: "form__submit form__add_nested_button" %> + <%= f.fields_for :conditions, [OIDC::AccessPolicy::Statement::Condition.new], child_index: 'NEW_OBJECT' do |f| %> + + <% end %> + <%= f.fields_for :conditions do |f| %> + <%= render(partial: "oidc/access_policy/statement/conditions/fields", locals: { f: }) %> + <%end%> +
+ <%= f.button t("oidc.api_key_role.form.remove_statement"), class: "form__submit form__remove_nested_button" %> +
diff --git a/app/views/oidc/access_policy/statements/_statement.html.erb b/app/views/oidc/access_policy/statements/_statement.html.erb new file mode 100644 index 00000000000..34ac597bf74 --- /dev/null +++ b/app/views/oidc/access_policy/statements/_statement.html.erb @@ -0,0 +1,10 @@ +
+
+
<%= statement.class.human_attribute_name(:effect) %>
+
<%= statement.effect %>
+
<%= statement.class.human_attribute_name(:principal) %>
+
<%= link_to statement.principal.oidc, statement.principal.oidc %>
+
<%= statement.class.human_attribute_name(:conditions) %>
+
<%= render statement.conditions %>
+
+
diff --git a/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb b/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb new file mode 100644 index 00000000000..0d95336776f --- /dev/null +++ b/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb @@ -0,0 +1,10 @@ +
+
+
<%= api_key_permissions.class.human_attribute_name :scopes %>
+
<%= to_sentence api_key_permissions.scopes %>
+
<%= api_key_permissions.class.human_attribute_name :valid_for %>
+
<%= duration_string api_key_permissions.valid_for %>
+
<%= api_key_permissions.class.human_attribute_name :gems %>
+
<%= to_sentence(api_key_permissions.gems.presence&.map { |gem| link_to(gem, rubygem_path(gem)) } || [t("api_keys.all_gems")]) %>
+
+
diff --git a/app/views/oidc/api_key_roles/_form.html.erb b/app/views/oidc/api_key_roles/_form.html.erb new file mode 100644 index 00000000000..05e13fffa50 --- /dev/null +++ b/app/views/oidc/api_key_roles/_form.html.erb @@ -0,0 +1,63 @@ +<% url_attrs = @api_key_role.persisted? ? {url: profile_oidc_api_key_role_path(@api_key_role.token)} : {} %> +<%= form_for [:profile, @api_key_role], **url_attrs do |f|%> + <%= error_messages_for @api_key_role %> + <%= f.label :name, t("oidc/api_key_roles.index.name"), class: "form__label" %> + <%= f.text_field :name, class: "form__input", autocomplete: :off %> +
+ <%= f.label :provider, t("oidc/api_key_roles.index.provider"), class: "form__label" %> +

+ <%= f.collection_select :oidc_provider_id, OIDC::Provider.all, :id, :issuer, selected: :provider_id, class: "form__input form__select" %> +

+
+ <%= f.label :api_key_permissions, t("api_keys.show.api_key_permissions"), class: "form__label" %> + <%= f.fields_for :api_key_permissions, f.object.api_key_permissions do |f|%> +
+
+ <%= f.label :scopes, t("api_keys.index.scopes"), class: "form__label" %> +
+ <%= f.collection_check_boxes :scopes, ApiKey::API_SCOPES, :to_s, :to_s, include_hidden: false do |b| %> +
+ <%= + b.check_box(class: "form__checkbox__input", id: b.value) + + b.label(class: "form__checkbox__label") do + t("api_keys.index.#{b.value}") + end + %> +
+ <% end %> +
+
+
+ <%= f.label :valid_for, t("api_keys.show.valid_for"), class: "form__label" %> + <%= f.text_field :valid_for, value: f.object.valid_for.iso8601, class: "form__input", list: f.field_id(:valid_for_suggestions), autocomplete: :off %> + <%= content_tag(:datalist, id: f.field_id(:valid_for_suggestions)) do %> + <%= options_from_collection_for_select([5.minutes, 15.minutes, 30.minutes, 1.hour, 6.hours, 1.day], :iso8601, :inspect) %> + <% end %> +
+
+ <%= f.label :gems, t("api_keys.new.rubygem_scope"), class: "form__label" %> +

<%= t("api_keys.new.rubygem_scope_info") %>

+ <%= f.collection_select :gems, current_user.rubygems.by_name, :name, :name, { include_blank: t("api_keys.all_gems"), include_hidden: false }, selected: :name, class: "form__input form__select", multiple: true %> +
+
+ <% end%> +
+
+ <%= f.label :access_policy, class: "form__label" %> + <%= f.fields_for :access_policy, f.object.access_policy do |f|%> +
+ <%= f.label :statements, class: "form__label" %> + <%= f.button t("oidc.api_key_role.form.add_statement"), class: "form__submit form__add_nested_button" %> + <%= f.fields_for :statements, [OIDC::AccessPolicy::Statement.new], child_index: 'NEW_OBJECT' do |f| %> + + <% end %> + <%= f.fields_for :statements, f.object.statements do |f| %> + <%= render(partial: "oidc/access_policy/statements/fields", locals: { f: }) %> + <% end %> +
+ <% end %> +
+ <%= f.submit class: "form__submit" %> +<% end %> diff --git a/app/views/oidc/api_key_roles/edit.html.erb b/app/views/oidc/api_key_roles/edit.html.erb new file mode 100644 index 00000000000..2e20afdc49b --- /dev/null +++ b/app/views/oidc/api_key_roles/edit.html.erb @@ -0,0 +1,4 @@ +<% @title = t(".edit_role") %> +
+ <%= render "form" %> +
diff --git a/app/views/oidc/api_key_roles/github_actions_workflow_view.rb b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb new file mode 100644 index 00000000000..57deade8519 --- /dev/null +++ b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +class OIDC::ApiKeyRoles::GitHubActionsWorkflowView < ApplicationView + include Phlex::Rails::Helpers::LinkTo + + attr_reader :api_key_role + + def initialize(api_key_role:) + @api_key_role = api_key_role + super() + end + + def template + self.title = t(".title") + + return if not_configured + + div(class: "t-body") do + p do + t(".configured_for_html", link_html: + single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : "a gem") + end + + p do + t(".to_automate_html", link_html: + single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : "a gem") + end + + header(class: "gem__code__header") do + h3(class: "t-list__heading l-mb-0") { code { ".github/workflows/push.yml" } } + button(class: "gem__code__icon", data: { "clipboard-target": "#workflow_yaml" }) { "=" } + span(class: "gem__code__tooltip--copy") { t(".copy_to_clipboard") } + span(class: "gem__code__tooltip--copied") { t(".copied") } + end + pre(class: "gem__code multiline") do + code(class: "multiline", id: "workflow_yaml") do + plain workflow_yaml + end + end + end + end + + private + + def gem_name + single_gem_role? ? api_key_role.api_key_permissions.gems.first : "YOUR_GEM_NAME" + end + + def workflow_yaml + YAML.safe_dump({ + on: { push: { tags: true } }, + jobs: { + push: { + "runs-on": "ubuntu-latest", + permissions: { + contents: "write", + "id-token": "write" + }, + steps: [ + { uses: "rubygems/configure-rubygems-credentials@main", + with: { "role-to-assume": api_key_role.token, audience: configured_audience }.compact }, + { uses: "actions/checkout@v4" }, + { name: "Set remote URL", run: set_remote_url_run }, + { name: "Set up Ruby", uses: "ruby/setup-ruby@v1", with: { "bundler-cache": true, "ruby-version": "ruby" } }, + { name: "Release", run: "bundle exec rake release" }, + { name: "Wait for release to propagate", run: await_run } + ] + } + } + }.deep_stringify_keys) + end + + def set_remote_url_run + <<~BASH + # Attribute commits to the last committer on HEAD + git config --global user.email "$(git log -1 --pretty=format:'%ae')" + git config --global user.name "$(git log -1 --pretty=format:'%an')" + git remote set-url origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY" + BASH + end + + def await_run + <<~BASH + gem install rubygems-await + gem_tuple="$(ruby -rbundler/setup -rbundler -e ' + spec = Bundler.definition.specs.find {|s| s.name == ARGV[0] } + raise "No spec for \#{ARGV[0]}" unless spec + print [spec.name, spec.version, spec.platform].join(":") + ' #{gem_name.dump})" + gem await "${gem_tuple}" + BASH + end + + def not_configured + is_github = api_key_role.provider.github_actions? + is_push = api_key_role.api_key_permissions.scopes.include?("push_rubygem") + return if is_github && is_push + div(class: "t-body") do + p { t(".not_github") } unless is_github + p { t(".not_push") } unless is_push + end + true + end + + def configured_audience + auds = api_key_role.access_policy.statements.flat_map do |s| + next unless s.effect == "allow" + + s.conditions.flat_map do |c| + c.value if c.claim == "aud" + end + end + auds.compact! + auds.uniq! + + return unless auds.size == 1 + aud = auds.first + aud if aud != "rubygems.org" # default in action + end + + def single_gem_role? + api_key_role.api_key_permissions.gems&.size == 1 + end +end diff --git a/app/views/oidc/api_key_roles/index.html.erb b/app/views/oidc/api_key_roles/index.html.erb new file mode 100644 index 00000000000..a2ea9b7bea1 --- /dev/null +++ b/app/views/oidc/api_key_roles/index.html.erb @@ -0,0 +1,40 @@ +<% @title = t(".api_key_roles") %> +
+

+ <%= button_to(t(".new_role"), new_profile_oidc_api_key_role_path, method: "get", class: "form__submit") %> +

+
+

<%= page_entries_info(@api_key_roles) %>

+
+ + + + + + + + + + <% @api_key_roles.each do |api_key_role| %> + + + + + + <% end %> + +
+ <%= OIDC::ApiKeyRole.human_attribute_name(:name) %> + + <%= OIDC::ApiKeyRole.human_attribute_name(:token) %> + + <%= OIDC::Provider.human_attribute_name(:issuer) %> +
+ <%= link_to api_key_role.name, profile_oidc_api_key_role_path(api_key_role.token) %> + + <%= api_key_role.token %> + + <%= link_to api_key_role.provider.issuer, profile_oidc_provider_path(api_key_role.provider) %> +
+ <%= paginate @api_key_roles %> +
diff --git a/app/views/oidc/api_key_roles/new.html.erb b/app/views/oidc/api_key_roles/new.html.erb new file mode 100644 index 00000000000..cdcb6ade327 --- /dev/null +++ b/app/views/oidc/api_key_roles/new.html.erb @@ -0,0 +1,4 @@ +<% @title = t(".title") %> +
+ <%= render "form" %> +
diff --git a/app/views/oidc/api_key_roles/show.html.erb b/app/views/oidc/api_key_roles/show.html.erb new file mode 100644 index 00000000000..1f151c3e90b --- /dev/null +++ b/app/views/oidc/api_key_roles/show.html.erb @@ -0,0 +1,32 @@ +<% @title = t(".api_key_role_name", name: @api_key_role.name) %> +
+ <% if @api_key_role.github_actions_push? %> +

<%= link_to t(".automate_gh_actions_publishing"), github_actions_workflow_profile_oidc_api_key_role_path(@api_key_role.token), class: "t-link t-underline" %> →

+ <% end %> +

<%= OIDC::ApiKeyRole.human_attribute_name(:token) %>

+
+ <%= @api_key_role.token %> +
+

<%= OIDC::ApiKeyRole.human_attribute_name(:provider) %>

+
+ <%= link_to t(".view_provider", issuer: @api_key_role.provider.issuer), profile_oidc_provider_path(@api_key_role.provider) %> → +
+

<%= OIDC::ApiKeyRole.human_attribute_name(:api_key_permissions) %>

+
+ <%= render @api_key_role.api_key_permissions %> +
+

<%= OIDC::ApiKeyRole.human_attribute_name(:access_policy) %>

+
+ <%= render @api_key_role.access_policy %> +
+ <%= button_to t(".edit_role"), edit_profile_oidc_api_key_role_path(@api_key_role.token), method: "get", class: "form__submit" %> +

<%= OIDC::ApiKeyRole.human_attribute_name(:id_tokens) %>

+
+
+

<%= page_entries_info(@id_tokens) %>

+
+ <% if @id_tokens.present? %> + <%= render OIDC::IdToken::TableComponent.new(id_tokens: @id_tokens) %> + <% end %> +
+
diff --git a/app/views/oidc/id_tokens/index_view.rb b/app/views/oidc/id_tokens/index_view.rb new file mode 100644 index 00000000000..3b72a71b99b --- /dev/null +++ b/app/views/oidc/id_tokens/index_view.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class OIDC::IdTokens::IndexView < ApplicationView + attr_reader :id_tokens + + def initialize(id_tokens:) + @id_tokens = id_tokens + super() + end + + def template + self.title = t(".title") + + div(class: "t-body") do + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(id_tokens) } + end + if id_tokens.present? + render OIDC::IdToken::TableComponent.new(id_tokens:) + plain helpers.paginate(id_tokens) + end + end + end +end diff --git a/app/views/oidc/id_tokens/show_view.rb b/app/views/oidc/id_tokens/show_view.rb new file mode 100644 index 00000000000..d650a75d226 --- /dev/null +++ b/app/views/oidc/id_tokens/show_view.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class OIDC::IdTokens::ShowView < ApplicationView + extend Dry::Initializer + include Phlex::Rails::Helpers::TimeTag + include Phlex::Rails::Helpers::LinkTo + + option :id_token + + def template # rubocop:disable Metrics/AbcSize + self.title = t(".title") + + div(class: "t-body") do + section(:created_at) { time_tag id_token.created_at } + section(:expires_at) { time_tag id_token.api_key.expires_at } + section(:jti) { code { id_token.jti } } + section(:api_key_role) { link_to id_token.api_key_role.name, profile_oidc_api_key_role_path(id_token.api_key_role.token) } + section(:provider) { link_to id_token.provider.issuer, profile_oidc_provider_path(id_token.provider) } + section(:claims) { render OIDC::IdToken::KeyValuePairsComponent.new(pairs: id_token.claims) } + section(:header) { render OIDC::IdToken::KeyValuePairsComponent.new(pairs: id_token.header) } + end + end + + private + + def section(header, &) + h3(class: "t-list__heading") { id_token.class.human_attribute_name(header) } + div(class: "push--s", &) + end +end diff --git a/app/views/oidc/providers/index_view.rb b/app/views/oidc/providers/index_view.rb new file mode 100644 index 00000000000..66ae29cd8da --- /dev/null +++ b/app/views/oidc/providers/index_view.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class OIDC::Providers::IndexView < ApplicationView + include Phlex::Rails::Helpers::LinkTo + + attr_reader :providers + + def initialize(providers:) + @providers = providers + super() + end + + def template + self.title = t(".title") + + div(class: "t-body") do + p do + t(".description_html") + end + hr + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(providers) } + end + ul do + providers.each do |provider| + li { link_to provider.issuer, profile_oidc_provider_path(provider) } + end + end + plain helpers.paginate(providers) + end + end +end diff --git a/app/views/oidc/providers/show_view.rb b/app/views/oidc/providers/show_view.rb new file mode 100644 index 00000000000..09c1999e616 --- /dev/null +++ b/app/views/oidc/providers/show_view.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class OIDC::Providers::ShowView < ApplicationView + include Phlex::Rails::Helpers::LinkTo + + attr_reader :provider + + def initialize(provider:) + @provider = provider + super() + end + + def template + self.title = t(".title") + + div(class: "") do + dl(class: "t-body provider_attributes") do + supported_attrs.each do |attr| + val = provider.configuration.send(attr) + next if val.blank? + dt { provider.configuration.class.human_attribute_name(attr) } + dd do + attr.end_with?("s_supported") ? tags_attr(attr, val) : text_attr(attr, val) + end + end + end + + div(class: "t-body") do + hr + h3(class: "t-list__heading") { "Roles" } + + div(class: "") do + api_key_roles = helpers.current_user.oidc_api_key_roles.where(provider:).page(0).per(10) + header(class: "gems__header push--s") do + p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(api_key_roles) } + end + render OIDC::ApiKeyRole::TableComponent.new(api_key_roles:) if api_key_roles.present? + end + end + end + end + + def supported_attrs + (provider.configuration.required_attributes + provider.configuration.optional_attributes).map!(&:to_s) + end + + def tags_attr(_attr, val) + ul(class: "tag-list") do + val.each do |t| + li { code { t } } + end + end + end + + def text_attr(attr, val) + code do + case attr + when "issuer", /_(uri|endpoint)$/ + link_to(val, val) + else + val + end + end + end +end diff --git a/app/views/rubygems/_aside.html.erb b/app/views/rubygems/_aside.html.erb index 7f56c004ab9..1c509a68025 100644 --- a/app/views/rubygems/_aside.html.erb +++ b/app/views/rubygems/_aside.html.erb @@ -1,13 +1,10 @@
- <% if github_data_params = github_params(@rubygem) %> <%= render partial: "rubygems/github_button", locals: { github_data_params: github_data_params } %> <% end %> - <% if @adoption %> <%= link_to "adoption", rubygem_adoptions_path(@rubygem), class: "adoption__tag" %> <% end %> -

<%= t('stats.index.total_downloads') %> @@ -18,14 +15,12 @@ <%= number_with_delimiter(@latest_version.downloads_count) %>

-

<%= pluralized_licenses_header @latest_version %>:

<%= formatted_licenses @latest_version.licenses %>

-

<%= t('.required_ruby_version') %>: @@ -36,7 +31,6 @@ <% end %>

- <% if @rubygem.metadata_mfa_required? %>

<%= t('.requires_mfa') %>: @@ -45,7 +39,6 @@

<% end %> - <% if @latest_version.rubygems_metadata_mfa_required? %>

<%= t('.released_with_mfa') %>: @@ -54,16 +47,14 @@

<% end %> - <% if @latest_version.required_rubygems_version != '>= 0' %>

<%= t('.required_rubygems_version') %>: - <%= @latest_version.required_rubygems_version %> + <%= @latest_version.required_rubygems_version %>

<% end %> -

<%= t '.links.header' %>:

<%- @versioned_links.each do |name, link| %> @@ -76,6 +67,7 @@ <%= report_abuse_link(@rubygem) %> <%= reverse_dependencies_link(@rubygem) %> <%= ownership_link(@rubygem) if @rubygem.owned_by?(current_user) %> + <%= oidc_api_key_role_links(@rubygem) if @rubygem.owned_by?(current_user) %> <%= resend_owner_confirmation_link(@rubygem) if @rubygem.unconfirmed_ownership?(current_user) %> <%= rubygem_adoptions_link(@rubygem) if @rubygem.owned_by?(current_user) || @rubygem.ownership_requestable?%>
diff --git a/app/views/settings/edit.html.erb b/app/views/settings/edit.html.erb index b49e1e056af..81d239543f9 100644 --- a/app/views/settings/edit.html.erb +++ b/app/views/settings/edit.html.erb @@ -69,6 +69,12 @@

<%= link_to t('api_keys.index.api_keys'), profile_api_keys_path %>

+<% if @user.oidc_api_key_roles.any? %> +
+

<%= link_to t('oidc.api_key_roles.index.api_key_roles'), profile_oidc_api_key_roles_path %>

+
+<% end %> + <% if @user.ownerships.any? %>

<%= link_to t("notifiers.show.title"), notifier_path %>

diff --git a/config/application.rb b/config/application.rb index 086b5afc3a9..d4b6d24bfb7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -55,6 +55,10 @@ class Application < Rails::Application config.toxic_domains_filepath = Rails.root.join("vendor", "toxic_domains_whole.txt") config.active_job.queue_adapter = :good_job + + config.autoload_paths << "#{root}/app/views" + config.autoload_paths << "#{root}/app/views/layouts" + config.autoload_paths << "#{root}/app/views/components" end def self.config diff --git a/config/brakeman.ignore b/config/brakeman.ignore index fa1ea084191..ab7ee81ab1a 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,85 @@ { "ignored_warnings": [ + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "076f564d79f0e732d93925ac722bf7276e121d6f96827c1e651540ca91fd1153", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/controllers/oidc/id_tokens_controller.rb", + "line": 17, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => OIDC::IdTokens::IndexView.new(:id_tokens => current_user.oidc_id_tokens.includes(:api_key, :api_key_role, :provider).page(params[:page].to_i).strict_loading), {})", + "render_path": null, + "location": { + "type": "method", + "class": "OIDC::IdTokensController", + "method": "index" + }, + "user_input": "params[:page]", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "0961cc0a168ef86428d84889a7092c841454aadea80c0294f6703bac98307444", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/oidc/api_key_roles/show.html.erb", + "line": 20, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => current_user.oidc_api_key_roles.includes(:provider).find_by!(:token => params.require(:token)).access_policy, {})", + "render_path": [ + { + "type": "controller", + "class": "OIDC::ApiKeyRolesController", + "method": "show", + "line": 27, + "file": "app/controllers/oidc/api_key_roles_controller.rb", + "rendered": { + "name": "oidc/api_key_roles/show", + "file": "app/views/oidc/api_key_roles/show.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "oidc/api_key_roles/show" + }, + "user_input": "params.require(:token)", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "7395657e2ba2c741584d62f34f8528f5c909c8ba67fe3b7d653643ab1bb40079", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/controllers/oidc/api_key_roles_controller.rb", + "line": 32, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => OIDC::ApiKeyRoles::GitHubActionsWorkflowView.new(:api_key_role => current_user.oidc_api_key_roles.includes(:provider).find_by!(:token => params.require(:token))), {})", + "render_path": null, + "location": { + "type": "method", + "class": "OIDC::ApiKeyRolesController", + "method": "github_actions_workflow" + }, + "user_input": "params.require(:token)", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" + }, { "warning_type": "Mass Assignment", "warning_code": 105, @@ -7,7 +87,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/api/v1/hook_relay_controller.rb", - "line": 23, + "line": 19, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.permit(:attempts, :account_id, :hook_id, :id, :max_attempts, :status, :stream, :failure_reason, :completed_at, :created_at, :request => ([:target_url]))", "render_path": null, @@ -22,8 +102,88 @@ 915 ], "note": "account_id is used to validate that the request indeed comes from hook relay" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "ab59d54552d08258896fd31f8f4342971b02124008afe35a1507a5d1eef438b6", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/controllers/oidc/providers_controller.rb", + "line": 13, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => OIDC::Providers::IndexView.new(:providers => OIDC::Provider.all.strict_loading.page(params[:page].to_i)), {})", + "render_path": null, + "location": { + "type": "method", + "class": "OIDC::ProvidersController", + "method": "index" + }, + "user_input": "params[:page]", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "f23085c93323ec923578bed895c153b25b5bc2e9b64687c05ce426da16e6c755", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/oidc/api_key_roles/show.html.erb", + "line": 16, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => current_user.oidc_api_key_roles.includes(:provider).find_by!(:token => params.require(:token)).api_key_permissions, {})", + "render_path": [ + { + "type": "controller", + "class": "OIDC::ApiKeyRolesController", + "method": "show", + "line": 27, + "file": "app/controllers/oidc/api_key_roles_controller.rb", + "rendered": { + "name": "oidc/api_key_roles/show", + "file": "app/views/oidc/api_key_roles/show.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "oidc/api_key_roles/show" + }, + "user_input": "params.require(:token)", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "fb6ebb2f4b1a58b85cf0e01691f09c395754282fdfe576750538fc3dc62c57b2", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/controllers/oidc/providers_controller.rb", + "line": 17, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => OIDC::Providers::ShowView.new(:provider => OIDC::Provider.find(params.require(:id))), {})", + "render_path": null, + "location": { + "type": "method", + "class": "OIDC::ProvidersController", + "method": "show" + }, + "user_input": "params.require(:id)", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" } ], - "updated": "2023-03-01 02:46:07 -0800", - "brakeman_version": "5.4.1" + "updated": "2023-10-17 10:36:55 -0700", + "brakeman_version": "6.0.1" } diff --git a/config/locales/de.yml b/config/locales/de.yml index 8b2cfaf34da..1dbb17d7337 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -50,6 +50,13 @@ de: full_name: Vollständiger Name handle: Benutzername password: Passwort + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: @@ -69,12 +76,18 @@ de: models: user: activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: + gems: + too_long: api_keys: create: success: @@ -562,6 +575,10 @@ de: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: dependencies: @@ -715,3 +732,55 @@ de: new_device: nickname: submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/locales/en.yml b/config/locales/en.yml index 76a43b7ed79..80ffe4218ee 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -59,6 +59,13 @@ en: full_name: Full name handle: Username password: Password + api_key: + oidc_api_key_role: OIDC API Key Role + oidc/id_token: + jti: JWT ID + api_key_role: API Key Role + oidc/api_key_role: + api_key_permissions: API Key Permissions errors: messages: unpwn: has previously appeared in a data breach and should not be used @@ -78,12 +85,18 @@ en: models: user: User activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: JWKS URI + id_token_signing_alg_values_supported: ID Token signing algorithms supported errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: "%{value} seconds must be between 5 minutes (300 seconds) and 1 day (86,400 seconds)" + gems: + too_long: "may include at most 1 gem" api_keys: create: success: "Created new API key" @@ -561,6 +574,10 @@ en: wiki: Wiki resend_ownership_confirmation: Resend confirmation ownership: Ownership + oidc: + api_key_role: + name: "OIDC: %{name}" + new: "OIDC: Create" reserved: reserved_namespace: This namespace is reserved by rubygems.org. dependencies: @@ -714,3 +731,55 @@ en: new_device: Register a new security device nickname: Nickname submit: Register device + oidc: + api_key_roles: + index: + api_key_roles: OIDC API Key Roles + new_role: Create API Key Role + show: + api_key_role_name: "API Key Role %{name}" + automate_gh_actions_publishing: "Automate Gem Publishing with GitHub Actions" + view_provider: "View provider %{issuer}" + edit_role: "Edit API Key Role" + git_hub_actions_workflow: + title: "OIDC Gem Push GitHub Actions Workflow" + configured_for_html: "This OIDC API Key Role is configured to allow pushing %{link_html} from GitHub Actions." + to_automate_html: "To automate releasing %{link_html} when a new tag is pushed, add the following workflow to your repository." + not_github: "This OIDC API Key Role is not configured for GitHub Actions." + not_push: "This OIDC API Key Role is not configured to allow pushing gems." + copy_to_clipboard: Copy to clipboard + copied: Copied! + new: + title: "New OIDC API Key Role" + update: + success: "OIDC API Key Role updated" + edit_role: "Edit API Key Role" + form: + add_condition: Add condition + remove_condition: Remove condition + add_statement: Add statement + remove_statement: Remove statement + providers: + index: + title: "OIDC Providers" + description_html: "These are the OIDC providers that have been configured for RubyGems.org.
Please reach out to support if you need another OIDC Provider added." + show: + title: "OIDC Provider" + id_tokens: + index: + title: "OIDC ID Tokens" + show: + title: "OIDC ID Token" + duration: + minutes: + other: "%{count} minutes" + one: "1 minute" + hours: + other: "%{count} hours" + one: "1 hour" + days: + other: "%{count} days" + one: "1 day" + seconds: + other: "%{count} seconds" + one: "1 second" diff --git a/config/locales/es.yml b/config/locales/es.yml index 12ed44f39d3..1361bbef11a 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -61,6 +61,13 @@ es: full_name: Nombre completo handle: Usuario password: Contraseña + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: @@ -80,12 +87,18 @@ es: models: user: activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: + gems: + too_long: api_keys: create: success: @@ -592,6 +605,10 @@ es: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: Este namespace está reservado por rubygems.org. dependencies: @@ -759,3 +776,55 @@ es: new_device: nickname: submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 696929d89ce..a64bf5f2601 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -61,6 +61,13 @@ fr: full_name: Nom complet handle: Pseudonyme password: Mot de passe + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: @@ -80,12 +87,18 @@ fr: models: user: activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: + gems: + too_long: api_keys: create: success: @@ -599,6 +612,10 @@ fr: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: Ce nom est réservé par rubygems.org. dependencies: @@ -765,3 +782,55 @@ fr: new_device: nickname: submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 66976e36499..3f7708a3a91 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -54,6 +54,13 @@ ja: full_name: フルネーム handle: ユーザー名 password: パスワード + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: 過去にデータ侵害を受けたためお使いになれません @@ -73,12 +80,18 @@ ja: models: user: ユーザー activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: "%{value}秒は5分(300秒)から1日(86,400秒)までの間でなければなりません" + gems: + too_long: api_keys: create: success: 新しいAPIキーを作成しました @@ -565,6 +578,10 @@ ja: wiki: Wiki resend_ownership_confirmation: 確認を再送 ownership: 所有者 + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: この名前空間はrubygems.orgにより予約されています。 dependencies: @@ -724,3 +741,55 @@ ja: new_device: 新しいセキュリティ機器を登録 nickname: ニックネーム submit: 機器を登録 + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 17d7b71425d..49fc0eaeba1 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -53,6 +53,13 @@ nl: full_name: Voor-en achternaam handle: Gebruikersnaam password: Wachtwoord + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: @@ -72,12 +79,18 @@ nl: models: user: activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: + gems: + too_long: api_keys: create: success: @@ -566,6 +579,10 @@ nl: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: dependencies: @@ -719,3 +736,55 @@ nl: new_device: nickname: submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 5bb974ba689..27d3b2a147d 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -60,6 +60,13 @@ pt-BR: full_name: Nome completo handle: Usuário password: Senha + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: já apareceu anteriormente em um vazamento de dados e não deve ser utilizada @@ -79,12 +86,18 @@ pt-BR: models: user: Usuário activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: + gems: + too_long: api_keys: create: success: @@ -577,6 +590,10 @@ pt-BR: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: This namespace is reserved by rubygems.org. dependencies: @@ -742,3 +759,55 @@ pt-BR: new_device: nickname: submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 263a0147095..62bef6f4901 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -55,6 +55,13 @@ zh-CN: full_name: 全名 handle: 用户名 password: 密码 + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: 曾出现过数据泄露,不应该再使用 @@ -74,12 +81,18 @@ zh-CN: models: user: 用户 activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: + gems: + too_long: api_keys: create: success: 新的 API 密钥已创建 @@ -573,6 +586,10 @@ zh-CN: wiki: Wiki resend_ownership_confirmation: 重新发送 ownership: 所有权 + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: 该命名空间由 RubyGems.org 保留。 dependencies: @@ -732,3 +749,55 @@ zh-CN: new_device: 创建一个新的安全设备 nickname: 昵称 submit: 注册设备 + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index 63cd0ab7d4b..40526258b0b 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -50,6 +50,13 @@ zh-TW: full_name: 全名 handle: 帳號 password: 密碼 + api_key: + oidc_api_key_role: + oidc/id_token: + jti: + api_key_role: + oidc/api_key_role: + api_key_permissions: errors: messages: unpwn: @@ -69,12 +76,18 @@ zh-TW: models: user: activemodel: + attributes: + oidc/provider/configuration: + jwks_uri: + id_token_signing_alg_values_supported: errors: models: oidc/api_key_permissions: attributes: valid_for: inclusion: + gems: + too_long: api_keys: create: success: @@ -549,6 +562,10 @@ zh-TW: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: dependencies: @@ -702,3 +719,55 @@ zh-TW: new_device: nickname: submit: + oidc: + api_key_roles: + index: + api_key_roles: + new_role: + show: + api_key_role_name: + automate_gh_actions_publishing: + view_provider: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + new: + title: + update: + success: + edit_role: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + providers: + index: + title: + description_html: + show: + title: + id_tokens: + index: + title: + show: + title: + duration: + minutes: + other: + one: + hours: + other: + one: + days: + other: + one: + seconds: + other: + one: diff --git a/config/routes.rb b/config/routes.rb index d524fb98dd9..d1bca3521ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -172,6 +172,17 @@ resources :api_keys do delete :reset, on: :collection end + + namespace :oidc do + resources :api_key_roles, param: :token, except: %i[destroy] do + member do + get 'github_actions_workflow' + end + end + resources :api_key_roles, param: :token, only: %i[show], constraints: { format: :json } + resources :id_tokens, only: %i[index show] + resources :providers, only: %i[index show] + end end resources :stats, only: :index get "/news" => 'news#show', as: 'legacy_news_path' diff --git a/db/seeds.rb b/db/seeds.rb index cf55d49955a..539ac0c49a3 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -61,7 +61,8 @@ Version.create_with( indexed: true, - pusher: author + pusher: author, + metadata: { "source_code_uri" => "https://github.com/example/#{rubygem1.name}" } ).find_or_create_by!(rubygem: rubygem0, number: "1.0.0", platform: "ruby", gem_platform: "ruby") Version.create_with( indexed: true @@ -161,14 +162,18 @@ github_oidc_provider = OIDC::Provider .create_with( configuration: { - issuer: "https://token.actions.githubusercontent.com", - jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER, + jwks_uri: "#{OIDC::Provider::GITHUB_ACTIONS_ISSUER}/.well-known/jwks", + subject_types_supported: %w[public pairwise], response_types_supported: ["id_token"], - subject_types_supported: ["public"], + claims_supported: %w[sub aud exp iat iss jti nbf ref repository repository_id repository_owner repository_owner_id + run_id run_number run_attempt actor actor_id workflow workflow_ref workflow_sha head_ref + base_ref event_name ref_type environment environment_node_id job_workflow_ref + job_workflow_sha repository_visibility runner_environment], id_token_signing_alg_values_supported: ["RS256"], - claims_supported: ["repo"] + scopes_supported: ["openid"] } - ).find_or_create_by!(issuer: "https://token.actions.githubusercontent.com") + ).find_or_create_by!(issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER) author_oidc_api_key_role = author.oidc_api_key_roles.create_with( api_key_permissions: { @@ -184,7 +189,7 @@ }, conditions: [{ operator: "string_equals", - claim: "repo", + claim: "repository", value: "rubygems/rubygem0" }], ] @@ -202,8 +207,47 @@ name: "push-rubygem-1-expired", ).tap do |api_key| OIDC::IdToken.find_or_create_by!( - api_key:, - jwt: { claims: {jti: "expired"}, header: {}}, + api_key:, + jwt: { + claims: { + aud: "https://oidc-api-token.rubygems.org", + exp: 1_692_643_030, + iat: 1_692_642_730, + iss: "https://token.actions.githubusercontent.com", + jti: "42b0b56e-ff54-4ed5-bd87-448af14176f1", + nbf: 1_692_642_130, + ref: "refs/heads/main", + sha: "a39b8e11d7804422b7ff4924b246492fd366ea6c", + sub: "repo:segiddins/oidc-test:ref:refs/heads/main", + actor: "segiddins", + run_id: "5930133091", + actor_id: "1946610", + base_ref: "", + head_ref: "", + ref_type: "branch", + workflow: ".github/workflows/token.yml", + event_name: "push", + repository: "segiddins/oidc-test", + run_number: "33", + run_attempt: "1", + workflow_ref: "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main", + workflow_sha: "a39b8e11d7804422b7ff4924b246492fd366ea6c", + ref_protected: "false", + repository_id: "620393838", + job_workflow_ref: "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main", + job_workflow_sha: "a39b8e11d7804422b7ff4924b246492fd366ea6c", + repository_owner: "segiddins", + runner_environment: "github-hosted", + repository_owner_id: "1946610", + repository_visibility: "public" + }, + header: { + alg: "RS256", + kid: "78167F727DEC5D801DD1C8784C704A1C880EC0E1", + typ: "JWT", + x5t: "eBZ_cn3sXYAd0ch4THBKHIgOwOE" + } + }, api_key_role: author_oidc_api_key_role ) api_key.touch(:expires_at, time: "2020-01-01T00:00:00Z") @@ -218,8 +262,8 @@ name: "push-rubygem-1-unexpired", ).tap do |api_key| OIDC::IdToken.find_or_create_by!( - api_key:, - jwt: { claims: {jti: "unexpired"}, header: {}}, + api_key:, + jwt: { claims: { jti: "unexpired" }, header: {} }, api_key_role: author_oidc_api_key_role ) end diff --git a/test/factories/oidc/api_key_role.rb b/test/factories/oidc/api_key_role.rb index 8af57b7d94b..97d9615ddfa 100644 --- a/test/factories/oidc/api_key_role.rb +++ b/test/factories/oidc/api_key_role.rb @@ -7,7 +7,7 @@ scopes: ["push_rubygem"] } end - name { "GitHub Pusher" } + sequence(:name) { |n| "GitHub Pusher #{n}" } access_policy do { statements: [ diff --git a/test/factories/oidc/id_token.rb b/test/factories/oidc/id_token.rb index 9746b76ffd1..6e222911a9b 100644 --- a/test/factories/oidc/id_token.rb +++ b/test/factories/oidc/id_token.rb @@ -1,7 +1,7 @@ FactoryBot.define do factory :oidc_id_token, class: "OIDC::IdToken" do api_key_role factory: :oidc_api_key_role - api_key { association :api_key, user: api_key_role.user, key: SecureRandom.hex(20) } + api_key { association :api_key, key: SecureRandom.hex(20), **api_key_role.api_key_permissions.create_params(api_key_role.user) } jwt do { claims: { diff --git a/test/functional/oidc/api_key_roles_controller_test.rb b/test/functional/oidc/api_key_roles_controller_test.rb new file mode 100644 index 00000000000..9f37a802079 --- /dev/null +++ b/test/functional/oidc/api_key_roles_controller_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class OIDC::ApiKeyRolesControllerTest < ActionController::TestCase + context "when not logged in" do + setup { @user = create(:user) } + + context "on GET to index" do + setup { get :index } + + should redirect_to("sign in") { sign_in_path } + end + end + + context "when logged in" do + setup do + @user = create(:user) + @api_key_role = create(:oidc_api_key_role, user: @user) + @id_token = create(:oidc_id_token, api_key_role: @api_key_role) + sign_in_as(@user) + end + + context "with a password session" do + setup do + session[:verification] = 10.minutes.from_now + session[:verified_user] = @user.id + end + + context "on GET to index" do + setup { get :index } + should respond_with :success + end + + context "on GET to show with id" do + setup { get :show, params: { token: @api_key_role.token } } + should respond_with :success + end + + context "on GET to show with nonexistent id" do + setup { get :show, params: { token: "DNE" } } + should respond_with :not_found + end + end + + context "without a password session" do + context "on GET to index" do + setup { get :index } + should redirect_to("verify session") { verify_session_path } + end + + context "on GET to show with id" do + setup { get :show, params: { token: @api_key_role.token } } + should redirect_to("verify session") { verify_session_path } + end + + context "on GET to show with nonexistent id" do + setup { get :show, params: { token: "DNE" } } + should redirect_to("verify session") { verify_session_path } + end + end + end +end diff --git a/test/integration/api/v1/oidc/api_key_roles_test.rb b/test/integration/api/v1/oidc/api_key_roles_test.rb index 73a1dd82236..689943a2a28 100644 --- a/test/integration/api/v1/oidc/api_key_roles_test.rb +++ b/test/integration/api/v1/oidc/api_key_roles_test.rb @@ -24,7 +24,7 @@ class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest "oidc_provider_id" => @role.oidc_provider_id, "user_id" => @user.id, "api_key_permissions" => { "scopes" => ["push_rubygem"], "valid_for" => 1800, "gems" => nil }, - "name" => "GitHub Pusher", + "name" => @role.name, "access_policy" => { "statements" => [ { "effect" => "allow", "principal" => { "oidc" => @role.provider.issuer }, @@ -252,7 +252,7 @@ def jwt(claims = @claims, key: @pkey) assert_match(/^rubygems_/, resp["rubygems_api_key"]) assert_equal({ "rubygems_api_key" => resp["rubygems_api_key"], - "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d", + "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d", "scopes" => ["push_rubygem"], "expires_at" => 30.minutes.from_now }, resp) @@ -281,7 +281,7 @@ def jwt(claims = @claims, key: @pkey) assert_match(/^rubygems_/, resp["rubygems_api_key"]) assert_equal({ "rubygems_api_key" => resp["rubygems_api_key"], - "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d", + "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d", "scopes" => ["push_rubygem"], "expires_at" => 30.minutes.from_now, "gem" => Rubygem.find_by!(name: gem_name).as_json @@ -327,7 +327,7 @@ def jwt(claims = @claims, key: @pkey) assert_match(/^rubygems_/, resp["rubygems_api_key"]) assert_equal({ "rubygems_api_key" => resp["rubygems_api_key"], - "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d", + "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d", "scopes" => ["push_rubygem"], "expires_at" => 30.minutes.from_now }, resp) diff --git a/test/integration/oidc/api_key_roles_controller_test.rb b/test/integration/oidc/api_key_roles_controller_test.rb new file mode 100644 index 00000000000..121bf9022de --- /dev/null +++ b/test/integration/oidc/api_key_roles_controller_test.rb @@ -0,0 +1,80 @@ +require "test_helper" + +class OIDC::ApiKeyRolesControllerIntegrationTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @id_token = create(:oidc_id_token, user: @user) + @api_key_role = @id_token.api_key_role + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "get show" do + get profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + end + + should "get index" do + get profile_oidc_api_key_roles_url + + assert_response :success + end + + should "get github_actions_workflow" do + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + end + + should "get github_actions_workflow with a github actions role" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + @api_key_role = create(:oidc_api_key_role, provider:, user: @user).token + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role) + + assert_response :success + end + + should "get github_actions_workflow with a github actions role scoped to a gem" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/#{rubygem.name}" }) + + @api_key_role = create(:oidc_api_key_role, + user: @user, + provider:, + api_key_permissions: { scopes: ["push_rubygem"], gems: [rubygem.name] }) + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + end + end + + context "without a verified session" do + should "redirect show to verify" do + get profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect index to verify" do + get profile_oidc_api_key_roles_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect github_actions_workflow to verify" do + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to verify_session_path + end + end +end diff --git a/test/integration/oidc/id_tokens_controller_test.rb b/test/integration/oidc/id_tokens_controller_test.rb new file mode 100644 index 00000000000..410d4a78b7c --- /dev/null +++ b/test/integration/oidc/id_tokens_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class OIDC::IdTokensControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @id_token = create(:oidc_id_token, user: @user) + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "get show" do + get profile_oidc_id_token_url(@id_token) + + assert_response :success + end + + should "get index" do + get profile_oidc_id_tokens_url + + assert_response :success + end + end + + context "without a verified session" do + should "redirect show to verify" do + get profile_oidc_id_token_url(@id_token) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect index to verify" do + get profile_oidc_id_tokens_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + end +end diff --git a/test/integration/oidc/providers_controller_test.rb b/test/integration/oidc/providers_controller_test.rb new file mode 100644 index 00000000000..fb0fd6b35f3 --- /dev/null +++ b/test/integration/oidc/providers_controller_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class OIDC::ProvidersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now) + post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD }) + + @id_token = create(:oidc_id_token, user: @user) + @provider = @id_token.provider + end + + context "with a verified session" do + setup do + post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD })) + end + + should "get show" do + get profile_oidc_provider_url(@provider) + + assert_response :success + end + + should "get index" do + get profile_oidc_providers_url + + assert_response :success + end + end + + context "without a verified session" do + should "redirect show to verify" do + get profile_oidc_provider_url(@provider) + + assert_response :redirect + assert_redirected_to verify_session_path + end + + should "redirect index to verify" do + get profile_oidc_providers_url + + assert_response :redirect + assert_redirected_to verify_session_path + end + end +end diff --git a/test/models/oidc/api_key_permissions_test.rb b/test/models/oidc/api_key_permissions_test.rb index 2d1b36cf0b7..6299861c8c9 100644 --- a/test/models/oidc/api_key_permissions_test.rb +++ b/test/models/oidc/api_key_permissions_test.rb @@ -24,6 +24,6 @@ class OIDC::ApiKeyPermissionsTest < ActiveSupport::TestCase permissions = OIDC::ApiKeyPermissions.new(gems: %w[a b]) permissions.validate - assert_equal ["is too long (maximum is 1 character)"], permissions.errors.messages[:gems] + assert_equal ["may include at most 1 gem"], permissions.errors.messages[:gems] end end diff --git a/test/models/oidc/api_key_role_test.rb b/test/models/oidc/api_key_role_test.rb index a25dad9083c..d2ffe123eff 100644 --- a/test/models/oidc/api_key_role_test.rb +++ b/test/models/oidc/api_key_role_test.rb @@ -45,6 +45,7 @@ class OIDC::ApiKeyRoleTest < ActiveSupport::TestCase @role.access_policy.statements = [OIDC::AccessPolicy::Statement.new( principal: { oidc: nil } )] + @role.provider = nil @role.validate assert_equal ["can't be blank"], @role.errors.messages[:"access_policy.statements[0].principal.oidc"]