From a2200a51b5cf083c7fdc6805178c0cf8ed054458 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Tue, 26 Sep 2023 22:51:06 -0700 Subject: [PATCH] Add HTML pages for OIDC resources under /profile/ --- .rubocop.yml | 5 + Gemfile | 1 + Gemfile.lock | 12 + .../javascripts/oidc_api_key_role_form.js | 32 +++ app/assets/stylesheets/layout.css | 3 + app/assets/stylesheets/modules/form.css | 35 ++- app/assets/stylesheets/modules/gem.css | 24 ++ app/assets/stylesheets/modules/oidc.css | 79 +++++++ app/assets/stylesheets/modules/search.css | 8 +- app/assets/stylesheets/type.css | 5 + .../api/v1/oidc/api_key_roles_controller.rb | 2 +- app/controllers/api_keys_controller.rb | 2 +- .../oidc/api_key_roles_controller.rb | 137 +++++++++++ app/controllers/oidc/id_tokens_controller.rb | 34 +++ app/controllers/oidc/providers_controller.rb | 30 +++ app/helpers/duration_helper.rb | 10 + app/helpers/oidc/api_key_roles_helper.rb | 2 + app/helpers/oidc/providers_helper.rb | 2 + app/helpers/rubygems_helper.rb | 19 ++ app/models/oidc/access_policy.rb | 10 +- app/models/oidc/api_key_permissions.rb | 8 + app/models/oidc/api_key_role.rb | 39 +++- app/models/oidc/provider.rb | 10 + app/views/api_keys/index.html.erb | 3 + app/views/application_view.rb | 13 ++ app/views/components/application_component.rb | 23 ++ .../oidc/api_key_role/table_component.rb | 44 ++++ .../id_token/key_value_pairs_component.rb | 19 ++ .../oidc/id_token/table_component.rb | 39 ++++ app/views/mailer/api_key_created.html.erb | 5 + .../access_policies/_access_policy.html.erb | 8 + .../statement/conditions/_condition.html.erb | 3 + .../statement/conditions/_fields.html.erb | 24 ++ .../access_policy/statements/_fields.html.erb | 32 +++ .../statements/_statement.html.erb | 10 + .../_api_key_permissions.html.erb | 10 + app/views/oidc/api_key_roles/_form.html.erb | 63 +++++ app/views/oidc/api_key_roles/edit.html.erb | 4 + .../github_actions_workflow_view.rb | 133 +++++++++++ app/views/oidc/api_key_roles/index.html.erb | 40 ++++ app/views/oidc/api_key_roles/new.html.erb | 4 + app/views/oidc/api_key_roles/show.html.erb | 41 ++++ app/views/oidc/id_tokens/index_view.rb | 24 ++ app/views/oidc/id_tokens/show_view.rb | 30 +++ app/views/oidc/providers/index_view.rb | 32 +++ app/views/oidc/providers/show_view.rb | 65 ++++++ app/views/rubygems/_aside.html.erb | 12 +- app/views/settings/edit.html.erb | 6 + config/application.rb | 4 + config/brakeman.ignore | 166 +++++++++++++- config/locales/de.yml | 80 +++++++ config/locales/en.yml | 81 +++++++ config/locales/es.yml | 80 +++++++ config/locales/fr.yml | 80 +++++++ config/locales/ja.yml | 80 +++++++ config/locales/nl.yml | 80 +++++++ config/locales/pt-BR.yml | 80 +++++++ config/locales/zh-CN.yml | 80 +++++++ config/locales/zh-TW.yml | 80 +++++++ config/routes.rb | 11 + config/rubygems.yml | 1 + ...829_add_deleted_at_to_oidc_api_key_role.rb | 5 + db/schema.rb | 3 +- db/seeds.rb | 66 +++++- test/factories/oidc/api_key_role.rb | 2 +- test/factories/oidc/id_token.rb | 8 +- .../oidc/api_key_roles_controller_test.rb | 61 +++++ .../api/v1/oidc/api_key_roles_test.rb | 33 ++- .../oidc/api_key_roles_controller_test.rb | 173 ++++++++++++++ .../oidc/id_tokens_controller_test.rb | 44 ++++ .../oidc/providers_controller_test.rb | 45 ++++ test/models/oidc/api_key_permissions_test.rb | 2 +- test/models/oidc/api_key_role_test.rb | 24 +- test/system/oidc_test.rb | 217 ++++++++++++++++++ test/unit/helpers/rubygems_helper_test.rb | 15 ++ 75 files changed, 2747 insertions(+), 50 deletions(-) create mode 100644 app/assets/javascripts/oidc_api_key_role_form.js create mode 100644 app/assets/stylesheets/modules/oidc.css create mode 100644 app/controllers/oidc/api_key_roles_controller.rb create mode 100644 app/controllers/oidc/id_tokens_controller.rb create mode 100644 app/controllers/oidc/providers_controller.rb create mode 100644 app/helpers/duration_helper.rb create mode 100644 app/helpers/oidc/api_key_roles_helper.rb create mode 100644 app/helpers/oidc/providers_helper.rb create mode 100644 app/views/application_view.rb create mode 100644 app/views/components/application_component.rb create mode 100644 app/views/components/oidc/api_key_role/table_component.rb create mode 100644 app/views/components/oidc/id_token/key_value_pairs_component.rb create mode 100644 app/views/components/oidc/id_token/table_component.rb create mode 100644 app/views/oidc/access_policies/_access_policy.html.erb create mode 100644 app/views/oidc/access_policy/statement/conditions/_condition.html.erb create mode 100644 app/views/oidc/access_policy/statement/conditions/_fields.html.erb create mode 100644 app/views/oidc/access_policy/statements/_fields.html.erb create mode 100644 app/views/oidc/access_policy/statements/_statement.html.erb create mode 100644 app/views/oidc/api_key_permissions/_api_key_permissions.html.erb create mode 100644 app/views/oidc/api_key_roles/_form.html.erb create mode 100644 app/views/oidc/api_key_roles/edit.html.erb create mode 100644 app/views/oidc/api_key_roles/github_actions_workflow_view.rb create mode 100644 app/views/oidc/api_key_roles/index.html.erb create mode 100644 app/views/oidc/api_key_roles/new.html.erb create mode 100644 app/views/oidc/api_key_roles/show.html.erb create mode 100644 app/views/oidc/id_tokens/index_view.rb create mode 100644 app/views/oidc/id_tokens/show_view.rb create mode 100644 app/views/oidc/providers/index_view.rb create mode 100644 app/views/oidc/providers/show_view.rb create mode 100644 db/migrate/20231018235829_add_deleted_at_to_oidc_api_key_role.rb create mode 100644 test/functional/oidc/api_key_roles_controller_test.rb create mode 100644 test/integration/oidc/api_key_roles_controller_test.rb create mode 100644 test/integration/oidc/id_tokens_controller_test.rb create mode 100644 test/integration/oidc/providers_controller_test.rb create mode 100644 test/system/oidc_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index ebe04a4ba1d..32917f9d948 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -47,6 +47,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 70c758b38d5..e892b17854f 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.42" diff --git a/Gemfile.lock b/Gemfile.lock index dc6c630279c..8e5f79965e7 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..76654487870 --- /dev/null +++ b/app/assets/javascripts/oidc_api_key_role_form.js @@ -0,0 +1,32 @@ +$(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(); +}); 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..cbe48a95a4b --- /dev/null +++ b/app/assets/stylesheets/modules/oidc.css @@ -0,0 +1,79 @@ +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.oidc_access_policy > dd > * + * { + border-top-width: 2px; + border-top-style: solid; + border-top-color: #e2e8f0; + margin-top: .125rem; + padding-top: .125rem; +} + +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/v1/oidc/api_key_roles_controller.rb b/app/controllers/api/v1/oidc/api_key_roles_controller.rb index 174efd3a150..77784d0e07a 100644 --- a/app/controllers/api/v1/oidc/api_key_roles_controller.rb +++ b/app/controllers/api/v1/oidc/api_key_roles_controller.rb @@ -65,7 +65,7 @@ def assume_role private def set_api_key_role - @api_key_role = OIDC::ApiKeyRole.find_by!(token: params.require(:token)) + @api_key_role = OIDC::ApiKeyRole.active.find_by!(token: params.require(:token)) end def decode_jwt 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..aa7e4b985a3 --- /dev/null +++ b/app/controllers/oidc/api_key_roles_controller.rb @@ -0,0 +1,137 @@ +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 :redirect_for_deleted, only: %i[edit update destroy] + before_action :set_page, only: :index + + def index + @api_key_roles = current_user.oidc_api_key_roles.active.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.api_key_permissions = OIDC::ApiKeyPermissions.new(gems: [], scopes: scopes) + + if rubygem + existing_role_names = current_user.oidc_api_key_roles.where("name ILIKE ?", "Push #{rubygem.name}%").pluck(:name) + @api_key_role.api_key_permissions.gems = [rubygem.name] + @api_key_role.name = if existing_role_names.present? + "Push #{rubygem.name} #{existing_role_names.length + 1}" + else + "Push #{rubygem.name}" + end + end + + 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 + + def destroy + if @api_key_role.update(deleted_at: Time.current) + redirect_to profile_oidc_api_key_roles_path, flash: { notice: t(".success") } + else + redirect_to profile_oidc_api_key_role_path(@api_key_role.token), + flash: { error: @api_key_role.errors.full_messages.to_sentence } + 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 + (request.query_string.present? ? "?#{request.query_string}" : "") + redirect_to verify_session_path + end + + def redirect_for_deleted + redirect_to profile_oidc_api_key_roles_path, flash: { error: t(".deleted") } if @api_key_role.deleted_at? + 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 0e1f4181f08..df62423fc10 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_permissions.rb b/app/models/oidc/api_key_permissions.rb index 30c5909f624..6dd011eaad5 100644 --- a/app/models/oidc/api_key_permissions.rb +++ b/app/models/oidc/api_key_permissions.rb @@ -18,6 +18,14 @@ def create_params(user) validates :gems, length: { maximum: 1 } + def gems=(gems) + if gems == [""] # all gems, from form + super(nil) + else + super + end + end + def known_scopes? scopes&.each_with_index do |scope, idx| errors.add("scopes[#{idx}]", "unknown scope: #{scope}") unless ApiKey::API_SCOPES.include?(scope.to_sym) diff --git a/app/models/oidc/api_key_role.rb b/app/models/oidc/api_key_role.rb index 97915987b84..e76a92e67fb 100644 --- a/app/models/oidc/api_key_role.rb +++ b/app/models/oidc/api_key_role.rb @@ -3,23 +3,56 @@ class OIDC::ApiKeyRole < ApplicationRecord belongs_to :user, inverse_of: :oidc_api_key_roles has_many :id_tokens, -> { order(created_at: :desc) }, - class_name: "OIDC::IdToken", inverse_of: :api_key_role, foreign_key: :oidc_api_key_role_id, dependent: :nullify + class_name: "OIDC::IdToken", inverse_of: :api_key_role, foreign_key: :oidc_api_key_role_id, dependent: :restrict_with_exception has_many :api_keys, through: :id_tokens, inverse_of: :oidc_api_key_role + scope :for_rubygem, lambda { |rubygem| + if rubygem.blank? + where("(jsonb_typeof((#{arel_table.name}.api_key_permissions->'gems')::jsonb) = 'null' OR " \ + "jsonb_array_length((#{arel_table.name}.api_key_permissions->'gems')::jsonb) = 0)") + else + where("(#{arel_table.name}.api_key_permissions->'gems')::jsonb @> ?", %([#{rubygem.name.to_json}])) + end + } + + scope :for_scope, lambda { |scope| + where("(#{arel_table.name}.api_key_permissions->'scopes')::jsonb @> ?", %([#{scope.to_json}])) + } + + scope :deleted, -> { where.not(deleted_at: nil) } + scope :active, -> { where(deleted_at: nil) } + + 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| + statement.principal ||= OIDC::AccessPolicy::Statement::Principal.new + 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 @@ -27,9 +60,9 @@ def all_condition_claims_are_known s.conditions&.each_with_index do |c, ci| unless known_claims&.include?(c.claim) errors.add("access_policy.statements[#{si}].conditions[#{ci}].claim", - "unknown claim for the provider") + "unknown for the provider") c.errors.add(:claim, - "unknown claim for the provider") + "unknown for the provider") end end end 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..3a43fb4101f 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? %> +

<%= link_to t("oidc.api_key_roles.index.api_key_roles"), profile_oidc_api_key_roles_path, class: "t-link t-underline" %> →

+ <% 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..b32deb27a7f --- /dev/null +++ b/app/views/components/application_component.rb @@ -0,0 +1,23 @@ +# 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 + + 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..20e6736d62a --- /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::LinkToUnlessCurrent + + 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_unless_current token.api_key_role.name, profile_oidc_api_key_role_path(token.api_key_role.token) } + td(class: "owners__cell") { link_to_unless_current 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..c60c41648cb --- /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..f18b88e9068 --- /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_roles.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_roles.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..b3c710988db --- /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, class: "form__label" %> + <%= f.text_field :name, class: "form__input", autocomplete: :off %> +
+ <%= f.label :oidc_provider_id, 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, 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") + + b.label(class: "form__checkbox__label") do + t("api_keys.index.#{b.value}") + end + %> +
+ <% end %> +
+
+
+ <%= f.label :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_roles.form.add_statement"), class: "form__submit form__add_nested_button" %> + <%= f.fields_for :statements, [OIDC::AccessPolicy::Statement.new(conditions: [OIDC::AccessPolicy::Statement::Condition.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..77f32a2c58a --- /dev/null +++ b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb @@ -0,0 +1,133 @@ +# 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)) : t(".a_gem")) + end + + p do + t(".to_automate_html", link_html: + single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : t(".a_gem")) + end + + p { t(".instructions_html") } + + header(class: "gem__code__header") do + h3(class: "t-list__heading l-mb-0") { code { ".github/workflows/push_gem.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: ["v*"] } }, + name: "Push Gem", + 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, "gem-server": gem_server_url }.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 #{"--source #{gem_server_url.dump} " if gem_server_url}"${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 gem_server_url + host = Gemcutter::HOST + return if host == "rubygems.org" # default in action + "https://#{host}" + 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..19afb8c8f42 --- /dev/null +++ b/app/views/oidc/api_key_roles/show.html.erb @@ -0,0 +1,41 @@ +<% @title = t(".api_key_role_name", name: @api_key_role.name) %> +
+ <% if @api_key_role.github_actions_push? && !@api_key_role.deleted_at? %> +

<%= 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 %> + <% if @api_key_role.deleted_at? %> +

+ <%= t(".deleted_at_html", time_html: time_tag(@api_key_role.deleted_at, time_ago_in_words(@api_key_role.deleted_at))) %> +

+ <% 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 %> +
+ + <% unless @api_key_role.deleted_at? %> + <%= button_to t(".edit_role"), edit_profile_oidc_api_key_role_path(@api_key_role.token), method: "get", class: "form__submit" %> + <%= button_to t(".delete_role"), profile_oidc_api_key_role_path(@api_key_role.token), method: "delete", class: "form__submit", data: { confirm: t(".confirm_delete") } %> + <% end %> +

<%= 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 6867845d4f1..93455bda012 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: @@ -563,6 +576,10 @@ de: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: dependencies: @@ -716,3 +733,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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 6d79c618682..391b427d0cb 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" @@ -562,6 +575,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: @@ -715,3 +732,67 @@ 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" + delete_role: "Delete API Key Role" + confirm_delete: "Are you sure you want to delete this role?" + deleted_at_html: "This role was deleted %{time_html} ago and can no longer be used." + edit: + 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! + a_gem: a gem + instructions_html: | + To release, bump the gem version and push a new tag (using rake release:source_control_push) to GitHub. The workflow will automatically build the gem and push it to RubyGems.org. + new: + title: "New OIDC API Key Role" + update: + success: "OIDC API Key Role updated" + create: + success: "OIDC API Key Role created" + destroy: + success: "OIDC API Key Role deleted" + form: + add_condition: Add condition + remove_condition: Remove condition + add_statement: Add statement + remove_statement: Remove statement + deleted: "The role has been deleted." + 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 578340cb809..872d8b8608b 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: @@ -593,6 +606,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: @@ -760,3 +777,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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 5d3381350dd..118c3fe286e 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: @@ -600,6 +613,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: @@ -766,3 +783,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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 412c703733e..ec9e544472c 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キーを作成しました @@ -566,6 +579,10 @@ ja: wiki: Wiki resend_ownership_confirmation: 確認を再送 ownership: 所有者 + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: この名前空間はrubygems.orgにより予約されています。 dependencies: @@ -725,3 +742,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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 3cfafe6a49e..2bedc1881ce 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: @@ -567,6 +580,10 @@ nl: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: dependencies: @@ -720,3 +737,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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 b5d37da55b5..b325ff3e11e 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: @@ -578,6 +591,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: @@ -743,3 +760,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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 c33e88a6227..567acc7ad11 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 密钥已创建 @@ -574,6 +587,10 @@ zh-CN: wiki: Wiki resend_ownership_confirmation: 重新发送 ownership: 所有权 + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: 该命名空间由 RubyGems.org 保留。 dependencies: @@ -733,3 +750,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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 49760690692..2418a316566 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: @@ -550,6 +563,10 @@ zh-TW: wiki: Wiki resend_ownership_confirmation: ownership: + oidc: + api_key_role: + name: + new: reserved: reserved_namespace: dependencies: @@ -703,3 +720,66 @@ 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: + delete_role: + confirm_delete: + deleted_at_html: + edit: + edit_role: + git_hub_actions_workflow: + title: + configured_for_html: + to_automate_html: + not_github: + not_push: + copy_to_clipboard: + copied: + a_gem: + instructions_html: + new: + title: + update: + success: + create: + success: + destroy: + success: + form: + add_condition: + remove_condition: + add_statement: + remove_statement: + deleted: + 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..d6c2d56e9c7 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 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/config/rubygems.yml b/config/rubygems.yml index 9e378ff16a6..96a4b2217bf 100644 --- a/config/rubygems.yml +++ b/config/rubygems.yml @@ -46,4 +46,5 @@ oidc-api-token: s3_region: us-west-2 s3_endpoint: s3-us-west-2.amazonaws.com s3_contents_bucket: contents.oregon.oidc-api-token.s3.rubygems.org + s3_compact_index_bucket: compact-index.oregon.oidc-api-token.s3.rubygems.org versions_file_location: "./config/versions.list" diff --git a/db/migrate/20231018235829_add_deleted_at_to_oidc_api_key_role.rb b/db/migrate/20231018235829_add_deleted_at_to_oidc_api_key_role.rb new file mode 100644 index 00000000000..9f5b77aa4b0 --- /dev/null +++ b/db/migrate/20231018235829_add_deleted_at_to_oidc_api_key_role.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToOIDCApiKeyRole < ActiveRecord::Migration[7.0] + def change + add_column :oidc_api_key_roles, :deleted_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index cf9fcfc6b51..a174e30eeeb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_09_26_202658) do +ActiveRecord::Schema[7.0].define(version: 2023_10_18_235829) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" enable_extension "pgcrypto" @@ -248,6 +248,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "token", limit: 32, null: false + t.datetime "deleted_at" t.index ["oidc_provider_id"], name: "index_oidc_api_key_roles_on_oidc_provider_id" t.index ["token"], name: "index_oidc_api_key_roles_on_token", unique: true t.index ["user_id"], name: "index_oidc_api_key_roles_on_user_id" 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..67277532d22 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: { @@ -9,7 +9,11 @@ claim2: "value2", jti: }, - header: {} + header: { + alg: "RS256", + kid: "test", + typ: "JWT" + } } end 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..32db380c24f 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 }, @@ -35,7 +35,8 @@ class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest }] } ] }, "created_at" => @role.created_at.as_json, - "updated_at" => @role.updated_at.as_json + "updated_at" => @role.updated_at.as_json, + "deleted_at" => nil } ], response.parsed_body end @@ -62,7 +63,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 }, @@ -73,7 +74,8 @@ class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest }] } ] }, "created_at" => @role.created_at.as_json, - "updated_at" => @role.updated_at.as_json + "updated_at" => @role.updated_at.as_json, + "deleted_at" => nil }, response.parsed_body ) end @@ -252,7 +254,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 +283,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 @@ -313,6 +315,23 @@ def jwt(claims = @claims, key: @pkey) end end + context "with a deleted role" do + setup do + @role.update!(deleted_at: Time.current) + end + + should "respond not found" do + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + should "return an API token" do post assume_role_api_v1_oidc_api_key_role_path(@role.token), params: { @@ -327,7 +346,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..644ff624f0c --- /dev/null +++ b/test/integration/oidc/api_key_roles_controller_test.rb @@ -0,0 +1,173 @@ +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 show format json" do + get profile_oidc_api_key_role_url(@api_key_role.token, format: :json) + + assert_response :success + end + + should "get index" do + get profile_oidc_api_key_roles_url + + assert_response :success + end + + should "get new" do + get new_profile_oidc_api_key_role_url + + assert_response :success + end + + should "get new scoped to a rubygem" do + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem) + get new_profile_oidc_api_key_role_url(rubygem: rubygem.name) + + assert_response :success + page.assert_selector :field, "oidc_api_key_role[name]", with: "Push #{rubygem.name}" + page.assert_selector :select, "Gem Scope", selected: [rubygem.name] + end + + should "get new scoped to a rubygem with a taken name" do + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem) + create(:oidc_api_key_role, name: "Push #{rubygem.name}", user: @user) + get new_profile_oidc_api_key_role_url(rubygem: rubygem.name) + + assert_response :success + page.assert_selector :field, "oidc_api_key_role[name]", with: "Push #{rubygem.name} 2" + page.assert_selector :select, "Gem Scope", selected: [rubygem.name] + 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 + + should "get github_actions_workflow with a configured aud" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + + @api_key_role = create(:oidc_api_key_role, + user: @user, + provider:, + access_policy: { statements: [{ effect: "allow", conditions: [{ claim: "aud", operator: "string_equals", value: "example.com" }] }] }) + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + page.assert_text "audience: example.com" + end + + should "get github_actions_workflow with a configured default aud" do + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + + @api_key_role = create(:oidc_api_key_role, + user: @user, + provider:, + access_policy: { statements: [{ effect: "allow", conditions: [{ claim: "aud", operator: "string_equals", value: "rubygems.org" }] }] }) + get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + page.assert_no_text "audience:" + end + + should "delete" do + delete profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to profile_oidc_api_key_roles_path + + follow_redirect! + + page.assert_no_text @api_key_role.token + + get profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :success + page.assert_selector "h2", text: /This role was deleted .+ ago and can no longer be used/ + + delete profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to profile_oidc_api_key_roles_path + + follow_redirect! + + page.assert_selector ".flash #flash_error", text: "The role has been deleted." + + get edit_profile_oidc_api_key_role_url(@api_key_role.token) + + assert_response :redirect + assert_redirected_to profile_oidc_api_key_roles_path + + follow_redirect! + + page.assert_selector ".flash #flash_error", text: "The role has been deleted." + 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..4752a2d77af 100644 --- a/test/models/oidc/api_key_role_test.rb +++ b/test/models/oidc/api_key_role_test.rb @@ -21,6 +21,27 @@ class OIDC::ApiKeyRoleTest < ActiveSupport::TestCase assert_match "string_equals", @role.pretty_inspect end + test "for_rubygem scope" do + user = @role.user + rubygem = create(:rubygem, owners: [user]) + rubygem_role = create(:oidc_api_key_role, api_key_permissions: { gems: [rubygem.name], scopes: ["push_rubygem"] }, user:) + create(:oidc_api_key_role, api_key_permissions: { gems: [create(:rubygem, owners: [user]).name], scopes: ["push_rubygem"] }, user:) + empty_gems = create(:oidc_api_key_role, api_key_permissions: { gems: [], scopes: ["push_rubygem"] }, user:) + nil_gems = create(:oidc_api_key_role, api_key_permissions: { gems: nil, scopes: ["push_rubygem"] }, user:) + + assert_equal [rubygem_role], OIDC::ApiKeyRole.for_rubygem(rubygem).to_a + assert_equal [@role, empty_gems, nil_gems], OIDC::ApiKeyRole.for_rubygem(nil).to_a + end + + test "for_scope scope" do + role1 = create(:oidc_api_key_role, api_key_permissions: { gems: [], scopes: %w[push_rubygem yank_rubygem] }) + role2 = create(:oidc_api_key_role, api_key_permissions: { gems: [], scopes: ["push_rubygem"] }) + + assert_equal [role1, role2], OIDC::ApiKeyRole.for_scope("push_rubygem").to_a + assert_equal [role1], OIDC::ApiKeyRole.for_scope("yank_rubygem").to_a + assert_predicate OIDC::ApiKeyRole.for_scope("show_dashboard"), :none? + end + test "validates gems belong to the user" do @role.api_key_permissions.gems = ["does_not_exist"] @role.validate @@ -38,13 +59,14 @@ class OIDC::ApiKeyRoleTest < ActiveSupport::TestCase )] @role.validate - assert_equal ["unknown claim for the provider"], @role.errors.messages[:"access_policy.statements[0].conditions[0].claim"] + assert_equal ["unknown for the provider"], @role.errors.messages[:"access_policy.statements[0].conditions[0].claim"] end test "validates nested models" do @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"] diff --git a/test/system/oidc_test.rb b/test/system/oidc_test.rb new file mode 100644 index 00000000000..7c4b17cbff4 --- /dev/null +++ b/test/system/oidc_test.rb @@ -0,0 +1,217 @@ +require "application_system_test_case" + +class OIDCTest < ApplicationSystemTestCase + setup do + @user = create(:user, password: PasswordHelpers::SECURE_TEST_PASSWORD) + @provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com") + @api_key_role = create(:oidc_api_key_role, user: @user, provider: @provider) + @id_token = create(:oidc_id_token, user: @user, api_key_role: @api_key_role) + end + + def sign_in + visit sign_in_path + fill_in "Email or Username", with: @user.reload.email + fill_in "Password", with: @user.password + click_button "Sign in" + end + + def verify_session # rubocop:disable Minitest/TestMethodName + page.assert_title(/^Confirm Password/) + fill_in "Password", with: @user.password + click_button "Confirm" + end + + test "viewing providers" do + sign_in + visit profile_oidc_providers_path + verify_session + + page.assert_selector "h1", text: "OIDC Providers" + page.assert_text(/displaying 1 provider/i) + page.click_link "https://token.actions.githubusercontent.com" + + page.assert_selector "h1", text: "OIDC Provider" + page.assert_text "https://token.actions.githubusercontent.com" + page.assert_text "https://token.actions.githubusercontent.com/.well-known/jwks" + page.assert_text(/Displaying 1 api key role/i) + assert_link @id_token.api_key_role.name, href: profile_oidc_api_key_role_path(@id_token.api_key_role.token) + end + + test "viewing api key roles" do + sign_in + visit profile_oidc_api_key_roles_path + verify_session + + page.assert_selector "h1", text: "OIDC API Key Roles" + page.assert_text(/displaying 1 api key role/i) + page.click_link @id_token.api_key_role.name + + page.assert_selector "h1", text: "API Key Role #{@id_token.api_key_role.name}" + page.assert_text @id_token.api_key_role.token + page.assert_text "Scopes\npush_rubygem" + page.assert_text "Gems\nAll Gems" + page.assert_text "Valid for\n30 minutes" + page.assert_text "Effect\nallow" + page.assert_text "Principal\nhttps://token.actions.githubusercontent.com" + page.assert_text "Conditions\nsub string_equals repo:segiddins/oidc-test:ref:refs/heads/main" + page.assert_text(/Displaying 1 id token/i) + assert_link "View provider https://token.actions.githubusercontent.com", href: profile_oidc_provider_path(@provider) + assert_link @id_token.jti, href: profile_oidc_id_token_path(@id_token) + end + + test "viewing id tokens" do + sign_in + visit profile_oidc_id_tokens_path + verify_session + + page.assert_selector "h1", text: "OIDC ID Tokens" + page.assert_text(/displaying 1 id token/i) + page.click_link @id_token.jti + + page.assert_selector "h1", text: "OIDC ID Token" + page.assert_text "CREATED AT\n#{@id_token.created_at.to_fs(:long)}" + page.assert_text "EXPIRES AT\n#{@id_token.api_key.expires_at.to_fs(:long)}" + page.assert_text "JWT ID\n#{@id_token.jti}" + assert_link @api_key_role.name, href: profile_oidc_api_key_role_path(@api_key_role.token) + assert_link "https://token.actions.githubusercontent.com", href: profile_oidc_provider_path(@provider) + page.assert_text "jti\n#{@id_token.jti}" + page.assert_text "claim1\nvalue1" + page.assert_text "claim2\nvalue2" + page.assert_text "typ\nJWT" + end + + test "creating an api key role" do + rubygem = create(:rubygem, owners: [@user]) + create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/repo" }) + + sign_in + visit rubygem_path(rubygem.slug) + click_link "OIDC: Create" + verify_session + + page.assert_selector "h1", text: "New OIDC API Key Role" + assert_field "Name", with: "Push #{rubygem.name}" + assert_select "OIDC provider", options: ["https://token.actions.githubusercontent.com"], selected: "https://token.actions.githubusercontent.com" + assert_checked_field "Push rubygem" + assert_field "Valid for", with: "PT30M" + assert_select "Gem Scope", options: ["All Gems", rubygem.name], selected: rubygem.name + + assert_select "Effect", options: %w[allow deny], selected: "allow", + id: "oidc_api_key_role_access_policy_statements_attributes_0_effect" + assert_field "Claim", with: "aud", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_0_claim" + assert_select "Operator", options: ["String Equals", "String Matches"], selected: "String Equals", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_0_operator" + assert_field "Value", with: Gemcutter::HOST, + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_0_value" + assert_field "Claim", with: "repository", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_claim" + assert_select "Operator", options: ["String Equals", "String Matches"], selected: "String Equals", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_operator" + assert_field "Value", with: "example/repo", + id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_value" + + page.scroll_to page.find(id: "oidc_api_key_role_access_policy_statements_attributes_0_conditions_attributes_1_claim") + + click_button "Create Api key role" + + page.assert_selector "h1", text: "API Key Role Push #{rubygem.name}" + + role = OIDC::ApiKeyRole.where(name: "Push #{rubygem.name}", user: @user, provider: @provider).sole + + token = role.token + expected = { + "name" => "Push #{rubygem.name}", + "token" => token, + "api_key_permissions" => { + "scopes" => ["push_rubygem"], + "valid_for" => 1800, + "gems" => [rubygem.name] + }, + "access_policy" => { + "statements" => [ + { + "effect" => "allow", + "principal" => { "oidc" => "https://token.actions.githubusercontent.com" }, + "conditions" => [ + { "operator" => "string_equals", "claim" => "aud", "value" => "localhost" }, + { "operator" => "string_equals", "claim" => "repository", "value" => "example/repo" } + ] + } + ] + } + } + + assert_equal(expected, role.as_json.slice(*expected.keys)) + + click_button "Edit API Key Role" + page.scroll_to :bottom + click_button "Update Api key role" + + page.assert_selector "h1", text: "API Key Role Push #{rubygem.name}" + assert_equal(expected, role.reload.as_json.slice(*expected.keys)) + + click_button "Edit API Key Role" + + click_button "Add statement" + + statements = page.find_all(id: /oidc_api_key_role_access_policy_statements_attributes_\d+_wrapper/) + + assert_equal 2, statements.size + + new_statement = statements.last + new_statement.select "deny", from: "Effect" + new_statement.fill_in "Claim", with: "sub" + new_statement.select "String Matches", from: "Operator" + new_statement.fill_in "Value", with: "repo:example/repo:ref:refs/tags/.*" + new_statement.click_button "Add condition" + new_condition = new_statement.find_all(id: /oidc_api_key_role_access_policy_statements_attributes_\d+_conditions_attributes_\d+_wrapper/).last + new_condition.fill_in "Claim", with: "fudge" + new_condition.select "String Equals", from: "Operator" + + statements.first.find_all("button", text: "Remove condition").last.click + + page.assert_selector("button.form__remove_nested_button", text: "Remove condition", count: 3) + + click_button "Update Api key role" + + page.assert_text "Access policy statements[1] conditions[1] claim unknown for the provider" + assert_equal(expected, role.reload.as_json.slice(*expected.keys)) + + page.find_field("Claim", with: "fudge").fill_in with: "event_name" + + page.find_field("Name").fill_in with: "Push gems" + page.select "All Gems", from: "Gem Scope" + page.unselect rubygem.name, from: "Gem Scope" + page.check "Yank rubygem" + + click_button "Update Api key role" + + page.assert_selector "h1", text: "API Key Role Push gems" + assert_equal(expected.merge( + "name" => "Push gems", + "api_key_permissions" => { + "scopes" => %w[push_rubygem yank_rubygem], "valid_for" => 1800, "gems" => nil + }, + "access_policy" => { + "statements" => [ + { + "effect" => "allow", + "principal" => { "oidc" => "https://token.actions.githubusercontent.com" }, + "conditions" => [ + { "operator" => "string_equals", "claim" => "aud", "value" => "localhost" } + ] + }, + { + "effect" => "allow", + "principal" => { "oidc" => "https://token.actions.githubusercontent.com" }, + "conditions" => [ + { "operator" => "string_matches", "claim" => "sub", "value" => "repo:example/repo:ref:refs/tags/.*" }, + { "operator" => "string_equals", "claim" => "event_name", "value" => "" } + ] + } + ] + } + ), role.reload.as_json.slice(*expected.keys)) + end +end diff --git a/test/unit/helpers/rubygems_helper_test.rb b/test/unit/helpers/rubygems_helper_test.rb index 9b250dfe6cc..adcca271fe7 100644 --- a/test/unit/helpers/rubygems_helper_test.rb +++ b/test/unit/helpers/rubygems_helper_test.rb @@ -207,6 +207,21 @@ class RubygemsHelperTest < ActionView::TestCase end end + context "oidc_api_key_role_links" do + should "return joined links" do + user = create(:user) + rubygem = create(:rubygem, name: "my_gem", owners: [user]) + role = create(:oidc_api_key_role, name: "Push my_gem", api_key_permissions: { gems: ["my_gem"], scopes: ["push_rubygem"] }, user: user) + stubs(:current_user).returns(user) + + role_link = link_to "OIDC: #{role.name}", profile_oidc_api_key_role_path(role.token), class: "gem__link t-list__item" + create_link = link_to "OIDC: Create", new_profile_oidc_api_key_role_path(rubygem: rubygem.name, scopes: ["push_rubygem"]), + class: "gem__link t-list__item" + + assert_equal safe_join([role_link, create_link]), oidc_api_key_role_links(rubygem) + end + end + context "change_diff_link" do context "with yanked version" do setup do