diff --git a/.rubocop.yml b/.rubocop.yml
index 0f22685992c..6b8ed5f560c 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -51,6 +51,11 @@ Rails/UniqueValidationWithoutIndex:
Rails/ActionControllerTestCase:
Enabled: false # Causes every integration test to fail
+Rails/Output:
+ Exclude:
+ - app/views/**/*_view.rb
+ - app/views/**/*_component.rb
+
Layout/ArgumentAlignment:
Enabled: false
diff --git a/Gemfile b/Gemfile
index 06ffa43e883..821c3c5fbfb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -53,6 +53,7 @@ gem "browser", "~> 5.3", ">= 5.3.1"
gem "bcrypt", "~> 3.1", ">= 3.1.18"
gem "maintenance_tasks", "~> 2.1"
gem "strong_migrations", "~> 1.6"
+gem "phlex-rails", "~> 1.0"
# Admin dashboard
gem "avo", "~> 2.28", "< 2.36" # 2.36+ requires to fix test failures
diff --git a/Gemfile.lock b/Gemfile.lock
index 29d5d26b0a5..97422fcd0a5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -141,6 +141,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.9.6)
+ cgi (0.3.6)
chartkick (5.0.4)
choice (0.2.0)
chunky_png (1.4.0)
@@ -206,6 +207,8 @@ GEM
dry-initializer (3.1.1)
email_validator (2.2.3)
activemodel
+ erb (4.0.3)
+ cgi (>= 0.3.3)
erubi (1.12.0)
et-orbi (1.2.7)
tzinfo
@@ -443,6 +446,14 @@ GEM
parser (3.2.1.1)
ast (~> 2.4.1)
pg (1.5.4)
+ phlex (1.8.1)
+ concurrent-ruby (~> 1.2)
+ erb (>= 4)
+ zeitwerk (~> 2.6)
+ phlex-rails (1.0.0)
+ phlex (~> 1.7)
+ rails (>= 6.1, < 8)
+ zeitwerk (~> 2.6)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -747,6 +758,7 @@ DEPENDENCIES
opensearch-dsl (~> 0.2.0)
opensearch-ruby (~> 1.0)
pg (~> 1.4)
+ phlex-rails (~> 1.0)
pry-byebug (~> 3.10)
puma (~> 6.1)
pundit (~> 2.3)
diff --git a/app/assets/javascripts/oidc_api_key_role_form.js b/app/assets/javascripts/oidc_api_key_role_form.js
new file mode 100644
index 00000000000..4257c60fd95
--- /dev/null
+++ b/app/assets/javascripts/oidc_api_key_role_form.js
@@ -0,0 +1,71 @@
+$(function () {
+ function wire() {
+ var removeNestedButtons = $("button.form__remove_nested_button");
+
+ removeNestedButtons.off("click");
+ removeNestedButtons.click(function (e) {
+ e.preventDefault();
+ var button = $(this);
+ var nestedField = button.closest(".form__nested_fields");
+
+ nestedField.remove();
+ });
+
+ var addNestedButtons = $("button.form__add_nested_button");
+ addNestedButtons.off("click");
+ addNestedButtons.click(function (e) {
+ e.preventDefault();
+ var button = $(this);
+ var nestedFields = button.siblings("template.form__nested_fields");
+
+ var content = nestedFields
+ .html()
+ .replace(/NEW_OBJECT/g, new Date().getTime());
+
+ $(content).insertAfter(button.siblings().last());
+
+ wire();
+ });
+ }
+
+ wire();
+
+ // var enableGemScopeCheckboxes = $(
+ // "#push_rubygem, #yank_rubygem, #add_owner, #remove_owner"
+ // );
+ // var hiddenRubygemId = "hidden_api_key_rubygem_id";
+ // toggleGemSelector();
+
+ // enableGemScopeCheckboxes.click(function () {
+ // toggleGemSelector();
+ // });
+
+ // function toggleGemSelector() {
+ // var isApplicableGemScopeSelected = enableGemScopeCheckboxes.is(":checked");
+ // var gemScopeSelector = $("#api_key_rubygem_id");
+
+ // if (isApplicableGemScopeSelected) {
+ // gemScopeSelector.removeAttr("disabled");
+ // removeHiddenRubygemField();
+ // } else {
+ // gemScopeSelector.val("");
+ // gemScopeSelector.prop("disabled", true);
+ // addHiddenRubygemField();
+ // }
+ // }
+
+ // function addHiddenRubygemField() {
+ // $("")
+ // .attr({
+ // type: "hidden",
+ // id: hiddenRubygemId,
+ // name: "api_key[rubygem_id]",
+ // value: "",
+ // })
+ // .appendTo(".t-body form .api_key_rubygem_id_form");
+ // }
+
+ // function removeHiddenRubygemField() {
+ // $("#" + hiddenRubygemId + ":hidden").remove();
+ // }
+});
diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css
index 20fb868cc2b..e35045439d5 100644
--- a/app/assets/stylesheets/layout.css
+++ b/app/assets/stylesheets/layout.css
@@ -17,6 +17,9 @@
.l-mr-4 {
margin-right: 1rem; }
+.l-mb-0 {
+ margin-bottom: 0 !important; }
+
.l-mb-4 {
margin-bottom: 1rem; }
diff --git a/app/assets/stylesheets/modules/form.css b/app/assets/stylesheets/modules/form.css
index 79514574d82..416e2207ac4 100644
--- a/app/assets/stylesheets/modules/form.css
+++ b/app/assets/stylesheets/modules/form.css
@@ -41,14 +41,21 @@
.form__input__addon-left .form__input {
padding-left: 45px; }
-.form__input, .form__textarea, .form__select, .form__group {
+.form__nested_fields, .form__input, .form__textarea, .form__select, .form__group {
margin-bottom: 30px; }
+.form__nested_fields, .form__input, .form__textarea {
+ display: block;
+ width: 100%;
+}
+
+.form__nested_fields {
+ margin: 12px 0 12px 32px;
+ width: calc(100% - 32px); }
+
.form__input, .form__textarea {
-webkit-appearance: none;
padding: 12px 16px;
- display: block;
- width: 100%;
font-weight: 300;
font-size: 18px;
border: 1px solid #f2f3f4;
@@ -219,3 +226,25 @@
.form__checkbox__item .field_with_errors {
display: contents;
}
+
+.form__flex_group {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 20px;
+}
+
+.form__flex_group > .form__submit {
+ margin: initial;
+ width: initial;
+ align-self: center;
+}
+
+.form__scope_checkbox_grid_group {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, 200px);
+ grid-gap: 20px;
+}
diff --git a/app/assets/stylesheets/modules/gem.css b/app/assets/stylesheets/modules/gem.css
index b35236d63ac..aeb2c8373c0 100644
--- a/app/assets/stylesheets/modules/gem.css
+++ b/app/assets/stylesheets/modules/gem.css
@@ -128,6 +128,11 @@
border: none;
font-weight: bold; }
+.gem__code.multiline {
+ line-height: inherit;
+ white-space: pre-wrap;
+ border-radius: 0; }
+
.gem__code::-webkit-scrollbar {
display: none; }
@@ -155,6 +160,25 @@
width: 10px;
background-image: linear-gradient(to right, transparent 0%, white 100%); }
+.gem__code__header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: .5rem;
+ border-top-left-radius: .25rem;
+ border-top-right-radius: .25rem;
+ border: #c1c4ca 1px solid;
+ border-bottom: 0; }
+
+.gem__code__header .gem__code__icon {
+ position: inherit;
+ padding: .125rem;
+ width: 40px; }
+
+.gem__code__icon.static {
+ position: static; }
+
.gem__code__tooltip--copy,
.gem__code__tooltip--copied {
display: none; }
diff --git a/app/assets/stylesheets/modules/oidc.css b/app/assets/stylesheets/modules/oidc.css
new file mode 100644
index 00000000000..d3240ef0d5f
--- /dev/null
+++ b/app/assets/stylesheets/modules/oidc.css
@@ -0,0 +1,71 @@
+dl.api_key_permissions {
+ margin-top: 1em;
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+}
+
+dl.api_key_permissions dt {
+ font-weight: bold;
+ float: inherit;
+}
+
+dl.oidc_access_policy {
+ display: grid;
+ column-gap: 1rem;
+ grid-template-columns: fit-content(6rem) auto;
+}
+
+dl.provider_attributes {
+ column-gap: 1rem;
+ align-items: baseline;
+ row-gap: 1rem;
+}
+
+@media (max-width: 420px){
+ dl.provider_attributes {
+ display: flex;
+ flex-direction: column;
+ }
+}
+@media (min-width: 421px){
+ dl.provider_attributes {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+dl.full-width {
+ width: 100%;
+ overflow-wrap: break-word;
+ word-break: break-word;
+
+}
+
+dl.provider_attributes dt.text-right {
+ text-align: right;
+}
+
+dl.provider_attributes dt.text-left {
+ text-align: left;
+}
+
+dl.provider_attributes dd ul.tag-list {
+ display: flex;
+ flex-direction: row;
+ list-style: none;
+ justify-content: start;
+ column-gap: 1rem;
+ flex-wrap: wrap;
+ align-items: baseline;
+ margin: 0;
+}
+
+dl.provider_attributes dd ul.tag-list li {
+ padding: 0.5rem 1rem;
+ background-color: #e2e8f0;
+ flex-shrink: 1;
+ border-radius: 9999px;
+}
+dl.provider_attributes dd ul.tag-list li:before {
+ height: 0;
+}
diff --git a/app/assets/stylesheets/modules/search.css b/app/assets/stylesheets/modules/search.css
index 75b143eaf47..c009b1b9347 100644
--- a/app/assets/stylesheets/modules/search.css
+++ b/app/assets/stylesheets/modules/search.css
@@ -135,11 +135,11 @@
color: white;
}
-dl {
+dl.search-fields {
margin: 6% 2%;
}
-dt {
+dl.search-fields dt {
float: left;
padding: 11px 0px;
color: #585858;
@@ -147,12 +147,12 @@ dt {
@media (min-width: 520px) {
- dd {
+ dl.search-fields dd {
margin-left: 25%;
}
}
-dd input {
+dl.search-fields dd input {
max-width: none !important;;
}
diff --git a/app/assets/stylesheets/type.css b/app/assets/stylesheets/type.css
index fbd38e0eeba..ecba22f18c1 100644
--- a/app/assets/stylesheets/type.css
+++ b/app/assets/stylesheets/type.css
@@ -125,6 +125,9 @@ a.t-list__item {
font-style: normal;
content: "→"; }
+.t-underline {
+ text-decoration: underline; }
+
.t-body p, .t-body ol li, .t-body ul li {
font-weight: 300;
font-size: 18px;
@@ -175,6 +178,8 @@ a.t-list__item {
overflow-x: scroll;
line-height: 1.33;
word-break: normal; }
+ .t-body pre code.multiline {
+ word-spacing: inherit; }
.t-body code {
font-weight: bold;
font-family: "courier", monospace;
diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb
index 205c385c90f..286b0b10afb 100644
--- a/app/controllers/api_keys_controller.rb
+++ b/app/controllers/api_keys_controller.rb
@@ -7,7 +7,7 @@ class ApiKeysController < ApplicationController
def index
@api_key = session.delete(:api_key)
- @api_keys = current_user.api_keys.unexpired
+ @api_keys = current_user.api_keys.unexpired.not_oidc
redirect_to new_profile_api_key_path if @api_keys.empty?
end
diff --git a/app/controllers/oidc/api_key_roles_controller.rb b/app/controllers/oidc/api_key_roles_controller.rb
new file mode 100644
index 00000000000..4f652a0fb0b
--- /dev/null
+++ b/app/controllers/oidc/api_key_roles_controller.rb
@@ -0,0 +1,116 @@
+class OIDC::ApiKeyRolesController < ApplicationController
+ include ApiKeyable
+
+ helper RubygemsHelper
+
+ before_action :redirect_to_signin, unless: :signed_in?
+ before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?
+ before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?
+ before_action :redirect_to_verify, unless: :password_session_active?
+ before_action :find_api_key_role, except: %i[index new create]
+ before_action :set_page, only: :index
+
+ def index
+ @api_key_roles = current_user.oidc_api_key_roles.includes(:provider)
+ .page(@page)
+ .strict_loading
+ end
+
+ def show
+ @id_tokens = @api_key_role.id_tokens.order(id: :desc).includes(:api_key)
+ .page(0).per(10)
+ .strict_loading
+ respond_to do |format|
+ format.json do
+ render json: @api_key_role
+ end
+ format.html
+ end
+ end
+
+ def github_actions_workflow
+ render OIDC::ApiKeyRoles::GitHubActionsWorkflowView.new(api_key_role: @api_key_role)
+ end
+
+ def new
+ rubygem = Rubygem.find_by(name: params[:rubygem])
+ scopes = params.permit(scopes: []).fetch(:scopes, [])
+ @api_key_role = current_user.oidc_api_key_roles.build
+ @api_key_role.name = "Push #{rubygem.name}" if rubygem
+ @api_key_role.api_key_permissions = OIDC::ApiKeyPermissions.new(
+ gems: rubygem ? [rubygem.name] : [],
+ scopes: scopes
+ )
+
+ condition = OIDC::AccessPolicy::Statement::Condition.new
+ statement = OIDC::AccessPolicy::Statement.new(conditions: [condition])
+ add_default_params(rubygem, statement, condition)
+
+ @api_key_role.access_policy = OIDC::AccessPolicy.new(statements: [statement])
+ end
+
+ def edit
+ end
+
+ def create
+ @api_key_role = current_user.oidc_api_key_roles.build(api_key_role_params)
+ if @api_key_role.save
+ redirect_to profile_oidc_api_key_role_path(@api_key_role.token), flash: { notice: t(".success") }
+ else
+ flash.now[:error] = @api_key_role.errors.full_messages.to_sentence
+ render :new
+ end
+ end
+
+ def update
+ if @api_key_role.update(api_key_role_params)
+ redirect_to profile_oidc_api_key_role_path(@api_key_role.token), flash: { notice: t(".success") }
+ else
+ flash.now[:error] = @api_key_role.errors.full_messages.to_sentence
+ render :edit
+ end
+ end
+
+ private
+
+ def find_api_key_role
+ @api_key_role = current_user.oidc_api_key_roles
+ .includes(:provider)
+ .find_by!(token: params.require(:token))
+ end
+
+ def redirect_to_verify
+ session[:redirect_uri] = request.path_info
+ redirect_to verify_session_path
+ end
+
+ def api_key_role_params
+ params.require(:oidc_api_key_role).permit(
+ :name, :oidc_provider_id,
+ api_key_permissions: [{ scopes: [] }, :valid_for, { gems: [] }],
+ access_policy: {
+ statements_attributes: [:effect, { principal: :oidc },
+ { conditions_attributes: %i[operator claim value] }]
+ }
+ )
+ end
+
+ def add_default_params(rubygem, statement, condition)
+ condition.claim = "aud"
+ condition.operator = "string_equals"
+ condition.value = Gemcutter::HOST
+
+ return unless rubygem
+ return unless (gh = helpers.link_to_github(rubygem)).presence
+ return unless (@api_key_role.provider = OIDC::Provider.github_actions)
+
+ statement.principal = { oidc: @api_key_role.provider.issuer }
+
+ repo_condition = OIDC::AccessPolicy::Statement::Condition.new(
+ claim: "repository",
+ operator: "string_equals",
+ value: gh.path.split("/")[1, 2].join("/")
+ )
+ statement.conditions << repo_condition
+ end
+end
diff --git a/app/controllers/oidc/id_tokens_controller.rb b/app/controllers/oidc/id_tokens_controller.rb
new file mode 100644
index 00000000000..c5e98a8a372
--- /dev/null
+++ b/app/controllers/oidc/id_tokens_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class OIDC::IdTokensController < ApplicationController
+ include ApiKeyable
+
+ before_action :redirect_to_signin, unless: :signed_in?
+ before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?
+ before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?
+ before_action :redirect_to_verify, unless: :password_session_active?
+ before_action :find_id_token, except: %i[index]
+ before_action :set_page, only: :index
+
+ def index
+ id_tokens = current_user.oidc_id_tokens.includes(:api_key, :api_key_role, :provider)
+ .page(@page)
+ .strict_loading
+ render OIDC::IdTokens::IndexView.new(id_tokens:)
+ end
+
+ def show
+ render OIDC::IdTokens::ShowView.new(id_token: @id_token)
+ end
+
+ private
+
+ def find_id_token
+ @id_token = current_user.oidc_id_tokens.find(params.require(:id))
+ end
+
+ def redirect_to_verify
+ session[:redirect_uri] = request.path_info
+ redirect_to verify_session_path
+ end
+end
diff --git a/app/controllers/oidc/providers_controller.rb b/app/controllers/oidc/providers_controller.rb
new file mode 100644
index 00000000000..d8eef9deb80
--- /dev/null
+++ b/app/controllers/oidc/providers_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class OIDC::ProvidersController < ApplicationController
+ before_action :redirect_to_signin, unless: :signed_in?
+ before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?
+ before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?
+ before_action :redirect_to_verify, unless: :password_session_active?
+ before_action :find_provider, except: %i[index]
+ before_action :set_page, only: :index
+
+ def index
+ providers = OIDC::Provider.all.strict_loading.page(@page)
+ render OIDC::Providers::IndexView.new(providers:)
+ end
+
+ def show
+ render OIDC::Providers::ShowView.new(provider: @provider)
+ end
+
+ private
+
+ def find_provider
+ @provider = OIDC::Provider.find(params.require(:id))
+ end
+
+ def redirect_to_verify
+ session[:redirect_uri] = request.path_info
+ redirect_to verify_session_path
+ end
+end
diff --git a/app/helpers/duration_helper.rb b/app/helpers/duration_helper.rb
new file mode 100644
index 00000000000..c341476ecfb
--- /dev/null
+++ b/app/helpers/duration_helper.rb
@@ -0,0 +1,10 @@
+module DurationHelper
+ def duration_string(duration)
+ parts = duration.parts
+ parts = { seconds: duration.value } if parts.empty?
+
+ to_sentence(parts
+ .sort_by { |unit, _| ActiveSupport::Duration::PARTS.index(unit) }
+ .map { |unit, val| t("duration.#{unit}", count: val) })
+ end
+end
diff --git a/app/helpers/oidc/api_key_roles_helper.rb b/app/helpers/oidc/api_key_roles_helper.rb
new file mode 100644
index 00000000000..4af601f03db
--- /dev/null
+++ b/app/helpers/oidc/api_key_roles_helper.rb
@@ -0,0 +1,2 @@
+module OIDC::ApiKeyRolesHelper
+end
diff --git a/app/helpers/oidc/providers_helper.rb b/app/helpers/oidc/providers_helper.rb
new file mode 100644
index 00000000000..ade07a70c24
--- /dev/null
+++ b/app/helpers/oidc/providers_helper.rb
@@ -0,0 +1,2 @@
+module OIDC::ProvidersHelper
+end
diff --git a/app/helpers/rubygems_helper.rb b/app/helpers/rubygems_helper.rb
index ccb379affea..163f14a5d1a 100644
--- a/app/helpers/rubygems_helper.rb
+++ b/app/helpers/rubygems_helper.rb
@@ -91,6 +91,25 @@ def ownership_link(rubygem)
link_to I18n.t("rubygems.aside.links.ownership"), rubygem_owners_path(rubygem.slug), class: "gem__link t-list__item"
end
+ def oidc_api_key_role_links(rubygem)
+ roles = current_user.oidc_api_key_roles.for_rubygem(rubygem)
+
+ links = roles.map do |role|
+ link_to(
+ t("rubygems.aside.links.oidc.api_key_role.name", name: role.name),
+ profile_oidc_api_key_role_path(role.token),
+ class: "gem__link t-list__item"
+ )
+ end
+ links << link_to(
+ t("rubygems.aside.links.oidc.api_key_role.new"),
+ new_profile_oidc_api_key_role_path(rubygem: rubygem.name, scopes: ["push_rubygem"]),
+ class: "gem__link t-list__item"
+ )
+
+ safe_join(links)
+ end
+
def resend_owner_confirmation_link(rubygem)
link_to I18n.t("rubygems.aside.links.resend_ownership_confirmation"),
resend_confirmation_rubygem_owners_path(rubygem.slug), class: "gem__link t-list__item"
diff --git a/app/models/oidc/access_policy.rb b/app/models/oidc/access_policy.rb
index 994eb6208e4..0ab65a4d78d 100644
--- a/app/models/oidc/access_policy.rb
+++ b/app/models/oidc/access_policy.rb
@@ -61,13 +61,21 @@ def value_expected_type?
validates :principal, presence: true, nested: true
- validates :conditions, nested: true
+ validates :conditions, nested: true, presence: true
+
+ def conditions_attributes=(attributes)
+ self.conditions = attributes.map { Condition.new(_2) }
+ end
end
attribute :statements, Types::ArrayOf.new(Types::JsonDeserializable.new(Statement))
validates :statements, presence: true, nested: true
+ def statements_attributes=(attributes)
+ self.statements = attributes.map { Statement.new(_2) }
+ end
+
class AccessError < StandardError
end
diff --git a/app/models/oidc/api_key_role.rb b/app/models/oidc/api_key_role.rb
index 97915987b84..96c1e50809a 100644
--- a/app/models/oidc/api_key_role.rb
+++ b/app/models/oidc/api_key_role.rb
@@ -6,20 +6,48 @@ class OIDC::ApiKeyRole < ApplicationRecord
class_name: "OIDC::IdToken", inverse_of: :api_key_role, foreign_key: :oidc_api_key_role_id, dependent: :nullify
has_many :api_keys, through: :id_tokens, inverse_of: :oidc_api_key_role
+ scope :for_rubygem, lambda { |rubygem|
+ if rubygem.blank?
+ where("(api_key_permissions->'gems')::jsonb <> JSONB ?", nil)
+ else
+ where("(api_key_permissions->'gems')::jsonb @> ?", %([#{rubygem.name.to_json}]))
+ end
+ }
+
+ scope :for_scope, lambda { |scope|
+ where("(api_key_permissions->'scopes')::jsonb @> ?", %([#{scope.to_json}]))
+ }
+
+ validates :name, presence: true, length: { maximum: 255 }, uniqueness: { scope: :user_id }
+
attribute :api_key_permissions, Types::JsonDeserializable.new(OIDC::ApiKeyPermissions)
validates :api_key_permissions, presence: true, nested: true
validate :gems_belong_to_user
+ def github_actions_push?
+ provider.github_actions? && api_key_permissions.scopes.include?("push_rubygem")
+ end
+
def gems_belong_to_user
Array.wrap(api_key_permissions&.gems).each_with_index do |name, idx|
errors.add("api_key_permissions.gems[#{idx}]", "(#{name}) does not belong to user #{user.display_handle}") if user.rubygems.where(name:).empty?
end
end
+ before_validation :set_statement_principals
attribute :access_policy, Types::JsonDeserializable.new(OIDC::AccessPolicy)
validates :access_policy, presence: true, nested: true
validate :all_condition_claims_are_known
+ # Since the only current value of this is the provider's issuer, we can set it automatically.
+ def set_statement_principals
+ return unless provider
+ access_policy&.statements&.each do |statement|
+ next if statement.principal.oidc.present?
+ statement.principal.oidc = provider.issuer
+ end
+ end
+
def all_condition_claims_are_known
return unless provider
known_claims = provider.configuration.claims_supported
diff --git a/app/models/oidc/provider.rb b/app/models/oidc/provider.rb
index f9380f794c4..52cc1a6e942 100644
--- a/app/models/oidc/provider.rb
+++ b/app/models/oidc/provider.rb
@@ -11,6 +11,16 @@ class OIDC::Provider < ApplicationRecord
has_many :audits, as: :auditable, dependent: :nullify
+ GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com".freeze
+
+ def self.github_actions
+ find_by(issuer: GITHUB_ACTIONS_ISSUER)
+ end
+
+ def github_actions?
+ issuer == GITHUB_ACTIONS_ISSUER
+ end
+
class Configuration < ::OpenIDConnect::Discovery::Provider::Config::Response
attr_optional required_attributes.delete(:authorization_endpoint)
diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb
index e64fea53d2b..0846fe8f622 100644
--- a/app/views/api_keys/index.html.erb
+++ b/app/views/api_keys/index.html.erb
@@ -107,4 +107,7 @@
<%= button_to t(".new_key"), new_profile_api_key_path, method: "get", class: "form__submit" %>
+ <% if current_user.oidc_api_key_roles.any? %>
+ <%= button_to t("oidc.api_key_roles.index.api_key_roles"), profile_oidc_api_key_roles_path, method: "get", class: "form__submit" %>
+ <% end %>
diff --git a/app/views/application_view.rb b/app/views/application_view.rb
new file mode 100644
index 00000000000..a46f657d1bd
--- /dev/null
+++ b/app/views/application_view.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ApplicationView < ApplicationComponent
+ # The ApplicationView is an abstract class for all your views.
+
+ # By default, it inherits from `ApplicationComponent`, but you
+ # can change that to `Phlex::HTML` if you want to keep views and
+ # components independent.
+
+ def title=(title)
+ @_view_context.instance_variable_set :@title, title
+ end
+end
diff --git a/app/views/components/application_component.rb b/app/views/components/application_component.rb
new file mode 100644
index 00000000000..e250dd3a109
--- /dev/null
+++ b/app/views/components/application_component.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class ApplicationComponent < Phlex::HTML
+ include Phlex::Rails::Helpers::Routes
+ include ActionView::Helpers::TranslationHelper
+
+ def self.translation_path
+ @translation_path ||= name&.dup.tap do |n|
+ n.gsub!(/(::[^:]+)View/, '\1')
+ n.gsub!("::", ".")
+ n.gsub!(/([a-z])([A-Z])/, '\1_\2')
+ n.downcase!
+ end
+ end
+
+ if Rails.env.development?
+ def before_template
+ comment { "Before #{self.class.name}" }
+ super
+ end
+ end
+
+ private
+
+ def scope_key_by_partial(key)
+ return key unless key&.start_with?(".")
+
+ "#{self.class.translation_path}#{key}"
+ end
+end
diff --git a/app/views/components/oidc/api_key_role/table_component.rb b/app/views/components/oidc/api_key_role/table_component.rb
new file mode 100644
index 00000000000..e7e0b037a13
--- /dev/null
+++ b/app/views/components/oidc/api_key_role/table_component.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class OIDC::ApiKeyRole::TableComponent < ApplicationComponent
+ include Phlex::Rails::Helpers::LinkTo
+
+ attr_reader :api_key_roles
+
+ def initialize(api_key_roles:)
+ @api_key_roles = api_key_roles
+ super()
+ end
+
+ def template
+ table(class: "t-body") do
+ thead do
+ tr(class: "owners__row owners__header") do
+ header { OIDC::ApiKeyRole.human_attribute_name(:name) }
+ header { OIDC::ApiKeyRole.human_attribute_name(:token) }
+ header { OIDC::ApiKeyRole.human_attribute_name(:issuer) }
+ end
+ end
+
+ tbody(class: "t-body") do
+ api_key_roles.each do |api_key_role|
+ tr(class: "owners__row") do
+ cell(title: "Name") { link_to api_key_role.name, profile_oidc_api_key_role_path(api_key_role.token) }
+ cell(title: "Role Token") { code { api_key_role.token } }
+ cell(title: "Provider") { link_to api_key_role.provider.issuer, api_key_role.provider.issuer }
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def header(&)
+ th(class: "owners_cell", &)
+ end
+
+ def cell(title:, &)
+ td(class: "owners__cell", data: { title: }, &)
+ end
+end
diff --git a/app/views/components/oidc/id_token/key_value_pairs_component.rb b/app/views/components/oidc/id_token/key_value_pairs_component.rb
new file mode 100644
index 00000000000..3c968740fe4
--- /dev/null
+++ b/app/views/components/oidc/id_token/key_value_pairs_component.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class OIDC::IdToken::KeyValuePairsComponent < ApplicationComponent
+ attr_reader :pairs
+
+ def initialize(pairs:)
+ @pairs = pairs
+ super()
+ end
+
+ def template
+ dl(class: "t-body provider_attributes full-width overflow-wrap") do
+ pairs.each do |key, val|
+ dt(class: "adoption__heading text-right") { code { key } }
+ dd { code { val } }
+ end
+ end
+ end
+end
diff --git a/app/views/components/oidc/id_token/table_component.rb b/app/views/components/oidc/id_token/table_component.rb
new file mode 100644
index 00000000000..c70dc685062
--- /dev/null
+++ b/app/views/components/oidc/id_token/table_component.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class OIDC::IdToken::TableComponent < ApplicationComponent
+ extend Dry::Initializer
+ option :id_tokens
+
+ include Phlex::Rails::Helpers::TimeTag
+ include Phlex::Rails::Helpers::LinkTo
+
+ def template
+ table(class: "owners__table") do
+ thead do
+ tr(class: "owners__row owners__header") do
+ th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:created_at) }
+ th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:expires_at) }
+ th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:api_key_role) }
+ th(class: "owners__cell") { OIDC::IdToken.human_attribute_name(:jti) }
+ end
+ end
+
+ tbody(class: "t-body") do
+ id_tokens.each do |token|
+ row(token)
+ end
+ end
+ end
+ end
+
+ private
+
+ def row(token)
+ tr(**classes("owners__row", -> { token.api_key.expired? } => "owners__row__invalid")) do
+ td(class: "owners__cell") { time_tag token.created_at }
+ td(class: "owners__cell") { time_tag token.api_key.expires_at }
+ td(class: "owners__cell") { link_to token.api_key_role.name, profile_oidc_api_key_role_path(token.api_key_role.token) }
+ td(class: "owners__cell") { link_to token.jti, profile_oidc_id_token_path(token), class: "recovery-code-list__item" }
+ end
+ end
+end
diff --git a/app/views/mailer/api_key_created.html.erb b/app/views/mailer/api_key_created.html.erb
index 05de1fe464f..6e711be7434 100644
--- a/app/views/mailer/api_key_created.html.erb
+++ b/app/views/mailer/api_key_created.html.erb
@@ -18,6 +18,11 @@
Scope: <%= @api_key.enabled_scopes.join(", ") %>
Created at: <%= @api_key.created_at.to_formatted_s(:rfc822) %>
+ <% if @api_key.oidc_id_token.present? %>
+
+ <%= ApiKey.human_attribute_name(:oidc_api_key_role) %>: <%= link_to(@api_key.oidc_api_key_role.name, profile_oidc_api_key_role_path(@api_key.oidc_api_key_role.token), target: :_blank) %>
+ <% end %>
+
<% if @api_key.name == "legacy-key" %>
diff --git a/app/views/oidc/access_policies/_access_policy.html.erb b/app/views/oidc/access_policies/_access_policy.html.erb
new file mode 100644
index 00000000000..289e3478779
--- /dev/null
+++ b/app/views/oidc/access_policies/_access_policy.html.erb
@@ -0,0 +1,8 @@
+
+
+ - <%= access_policy.class.human_attribute_name :statements %>
+ -
+ <%= render access_policy.statements %>
+
+
+
diff --git a/app/views/oidc/access_policy/statement/conditions/_condition.html.erb b/app/views/oidc/access_policy/statement/conditions/_condition.html.erb
new file mode 100644
index 00000000000..6a450ff1c21
--- /dev/null
+++ b/app/views/oidc/access_policy/statement/conditions/_condition.html.erb
@@ -0,0 +1,3 @@
+
+ <%= condition.claim %>
<%= condition.operator %> <%= condition.value %>
+
diff --git a/app/views/oidc/access_policy/statement/conditions/_fields.html.erb b/app/views/oidc/access_policy/statement/conditions/_fields.html.erb
new file mode 100644
index 00000000000..3990e722607
--- /dev/null
+++ b/app/views/oidc/access_policy/statement/conditions/_fields.html.erb
@@ -0,0 +1,24 @@
+
diff --git a/app/views/oidc/access_policy/statements/_fields.html.erb b/app/views/oidc/access_policy/statements/_fields.html.erb
new file mode 100644
index 00000000000..eb13201f95a
--- /dev/null
+++ b/app/views/oidc/access_policy/statements/_fields.html.erb
@@ -0,0 +1,32 @@
+
diff --git a/app/views/oidc/access_policy/statements/_statement.html.erb b/app/views/oidc/access_policy/statements/_statement.html.erb
new file mode 100644
index 00000000000..34ac597bf74
--- /dev/null
+++ b/app/views/oidc/access_policy/statements/_statement.html.erb
@@ -0,0 +1,10 @@
+
+
+ - <%= statement.class.human_attribute_name(:effect) %>
+ <%= statement.effect %>
+ - <%= statement.class.human_attribute_name(:principal) %>
+ - <%= link_to statement.principal.oidc, statement.principal.oidc %>
+ - <%= statement.class.human_attribute_name(:conditions) %>
+ - <%= render statement.conditions %>
+
+
diff --git a/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb b/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb
new file mode 100644
index 00000000000..0d95336776f
--- /dev/null
+++ b/app/views/oidc/api_key_permissions/_api_key_permissions.html.erb
@@ -0,0 +1,10 @@
+
+
+ - <%= api_key_permissions.class.human_attribute_name :scopes %>
+ - <%= to_sentence api_key_permissions.scopes %>
+ - <%= api_key_permissions.class.human_attribute_name :valid_for %>
+ - <%= duration_string api_key_permissions.valid_for %>
+ - <%= api_key_permissions.class.human_attribute_name :gems %>
+ - <%= to_sentence(api_key_permissions.gems.presence&.map { |gem| link_to(gem, rubygem_path(gem)) } || [t("api_keys.all_gems")]) %>
+
+
diff --git a/app/views/oidc/api_key_roles/_form.html.erb b/app/views/oidc/api_key_roles/_form.html.erb
new file mode 100644
index 00000000000..05e13fffa50
--- /dev/null
+++ b/app/views/oidc/api_key_roles/_form.html.erb
@@ -0,0 +1,63 @@
+<% url_attrs = @api_key_role.persisted? ? {url: profile_oidc_api_key_role_path(@api_key_role.token)} : {} %>
+<%= form_for [:profile, @api_key_role], **url_attrs do |f|%>
+ <%= error_messages_for @api_key_role %>
+ <%= f.label :name, t("oidc/api_key_roles.index.name"), class: "form__label" %>
+ <%= f.text_field :name, class: "form__input", autocomplete: :off %>
+
+
+
+ <%= f.submit class: "form__submit" %>
+<% end %>
diff --git a/app/views/oidc/api_key_roles/edit.html.erb b/app/views/oidc/api_key_roles/edit.html.erb
new file mode 100644
index 00000000000..2e20afdc49b
--- /dev/null
+++ b/app/views/oidc/api_key_roles/edit.html.erb
@@ -0,0 +1,4 @@
+<% @title = t(".edit_role") %>
+
+ <%= render "form" %>
+
diff --git a/app/views/oidc/api_key_roles/github_actions_workflow_view.rb b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb
new file mode 100644
index 00000000000..57deade8519
--- /dev/null
+++ b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+class OIDC::ApiKeyRoles::GitHubActionsWorkflowView < ApplicationView
+ include Phlex::Rails::Helpers::LinkTo
+
+ attr_reader :api_key_role
+
+ def initialize(api_key_role:)
+ @api_key_role = api_key_role
+ super()
+ end
+
+ def template
+ self.title = t(".title")
+
+ return if not_configured
+
+ div(class: "t-body") do
+ p do
+ t(".configured_for_html", link_html:
+ single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : "a gem")
+ end
+
+ p do
+ t(".to_automate_html", link_html:
+ single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : "a gem")
+ end
+
+ header(class: "gem__code__header") do
+ h3(class: "t-list__heading l-mb-0") { code { ".github/workflows/push.yml" } }
+ button(class: "gem__code__icon", data: { "clipboard-target": "#workflow_yaml" }) { "=" }
+ span(class: "gem__code__tooltip--copy") { t(".copy_to_clipboard") }
+ span(class: "gem__code__tooltip--copied") { t(".copied") }
+ end
+ pre(class: "gem__code multiline") do
+ code(class: "multiline", id: "workflow_yaml") do
+ plain workflow_yaml
+ end
+ end
+ end
+ end
+
+ private
+
+ def gem_name
+ single_gem_role? ? api_key_role.api_key_permissions.gems.first : "YOUR_GEM_NAME"
+ end
+
+ def workflow_yaml
+ YAML.safe_dump({
+ on: { push: { tags: true } },
+ jobs: {
+ push: {
+ "runs-on": "ubuntu-latest",
+ permissions: {
+ contents: "write",
+ "id-token": "write"
+ },
+ steps: [
+ { uses: "rubygems/configure-rubygems-credentials@main",
+ with: { "role-to-assume": api_key_role.token, audience: configured_audience }.compact },
+ { uses: "actions/checkout@v4" },
+ { name: "Set remote URL", run: set_remote_url_run },
+ { name: "Set up Ruby", uses: "ruby/setup-ruby@v1", with: { "bundler-cache": true, "ruby-version": "ruby" } },
+ { name: "Release", run: "bundle exec rake release" },
+ { name: "Wait for release to propagate", run: await_run }
+ ]
+ }
+ }
+ }.deep_stringify_keys)
+ end
+
+ def set_remote_url_run
+ <<~BASH
+ # Attribute commits to the last committer on HEAD
+ git config --global user.email "$(git log -1 --pretty=format:'%ae')"
+ git config --global user.name "$(git log -1 --pretty=format:'%an')"
+ git remote set-url origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY"
+ BASH
+ end
+
+ def await_run
+ <<~BASH
+ gem install rubygems-await
+ gem_tuple="$(ruby -rbundler/setup -rbundler -e '
+ spec = Bundler.definition.specs.find {|s| s.name == ARGV[0] }
+ raise "No spec for \#{ARGV[0]}" unless spec
+ print [spec.name, spec.version, spec.platform].join(":")
+ ' #{gem_name.dump})"
+ gem await "${gem_tuple}"
+ BASH
+ end
+
+ def not_configured
+ is_github = api_key_role.provider.github_actions?
+ is_push = api_key_role.api_key_permissions.scopes.include?("push_rubygem")
+ return if is_github && is_push
+ div(class: "t-body") do
+ p { t(".not_github") } unless is_github
+ p { t(".not_push") } unless is_push
+ end
+ true
+ end
+
+ def configured_audience
+ auds = api_key_role.access_policy.statements.flat_map do |s|
+ next unless s.effect == "allow"
+
+ s.conditions.flat_map do |c|
+ c.value if c.claim == "aud"
+ end
+ end
+ auds.compact!
+ auds.uniq!
+
+ return unless auds.size == 1
+ aud = auds.first
+ aud if aud != "rubygems.org" # default in action
+ end
+
+ def single_gem_role?
+ api_key_role.api_key_permissions.gems&.size == 1
+ end
+end
diff --git a/app/views/oidc/api_key_roles/index.html.erb b/app/views/oidc/api_key_roles/index.html.erb
new file mode 100644
index 00000000000..a2ea9b7bea1
--- /dev/null
+++ b/app/views/oidc/api_key_roles/index.html.erb
@@ -0,0 +1,40 @@
+<% @title = t(".api_key_roles") %>
+
+
+ <%= button_to(t(".new_role"), new_profile_oidc_api_key_role_path, method: "get", class: "form__submit") %>
+
+
+
+
+
+
+
+ <% @api_key_roles.each do |api_key_role| %>
+
+
+ <%= 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) %>
+ |
+
+ <% end %>
+
+
+ <%= paginate @api_key_roles %>
+
diff --git a/app/views/oidc/api_key_roles/new.html.erb b/app/views/oidc/api_key_roles/new.html.erb
new file mode 100644
index 00000000000..cdcb6ade327
--- /dev/null
+++ b/app/views/oidc/api_key_roles/new.html.erb
@@ -0,0 +1,4 @@
+<% @title = t(".title") %>
+
+ <%= render "form" %>
+
diff --git a/app/views/oidc/api_key_roles/show.html.erb b/app/views/oidc/api_key_roles/show.html.erb
new file mode 100644
index 00000000000..1f151c3e90b
--- /dev/null
+++ b/app/views/oidc/api_key_roles/show.html.erb
@@ -0,0 +1,32 @@
+<% @title = t(".api_key_role_name", name: @api_key_role.name) %>
+
+ <% if @api_key_role.github_actions_push? %>
+
<%= link_to t(".automate_gh_actions_publishing"), github_actions_workflow_profile_oidc_api_key_role_path(@api_key_role.token), class: "t-link t-underline" %> →
+ <% end %>
+
<%= OIDC::ApiKeyRole.human_attribute_name(:token) %>
+
+ <%= @api_key_role.token %>
+
+
<%= OIDC::ApiKeyRole.human_attribute_name(:provider) %>
+
+ <%= link_to t(".view_provider", issuer: @api_key_role.provider.issuer), profile_oidc_provider_path(@api_key_role.provider) %> →
+
+
<%= OIDC::ApiKeyRole.human_attribute_name(:api_key_permissions) %>
+
+ <%= render @api_key_role.api_key_permissions %>
+
+
<%= OIDC::ApiKeyRole.human_attribute_name(:access_policy) %>
+
+ <%= render @api_key_role.access_policy %>
+
+ <%= button_to t(".edit_role"), edit_profile_oidc_api_key_role_path(@api_key_role.token), method: "get", class: "form__submit" %>
+
<%= OIDC::ApiKeyRole.human_attribute_name(:id_tokens) %>
+
+
+ <% if @id_tokens.present? %>
+ <%= render OIDC::IdToken::TableComponent.new(id_tokens: @id_tokens) %>
+ <% end %>
+
+
diff --git a/app/views/oidc/id_tokens/index_view.rb b/app/views/oidc/id_tokens/index_view.rb
new file mode 100644
index 00000000000..3b72a71b99b
--- /dev/null
+++ b/app/views/oidc/id_tokens/index_view.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class OIDC::IdTokens::IndexView < ApplicationView
+ attr_reader :id_tokens
+
+ def initialize(id_tokens:)
+ @id_tokens = id_tokens
+ super()
+ end
+
+ def template
+ self.title = t(".title")
+
+ div(class: "t-body") do
+ header(class: "gems__header push--s") do
+ p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(id_tokens) }
+ end
+ if id_tokens.present?
+ render OIDC::IdToken::TableComponent.new(id_tokens:)
+ plain helpers.paginate(id_tokens)
+ end
+ end
+ end
+end
diff --git a/app/views/oidc/id_tokens/show_view.rb b/app/views/oidc/id_tokens/show_view.rb
new file mode 100644
index 00000000000..d650a75d226
--- /dev/null
+++ b/app/views/oidc/id_tokens/show_view.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class OIDC::IdTokens::ShowView < ApplicationView
+ extend Dry::Initializer
+ include Phlex::Rails::Helpers::TimeTag
+ include Phlex::Rails::Helpers::LinkTo
+
+ option :id_token
+
+ def template # rubocop:disable Metrics/AbcSize
+ self.title = t(".title")
+
+ div(class: "t-body") do
+ section(:created_at) { time_tag id_token.created_at }
+ section(:expires_at) { time_tag id_token.api_key.expires_at }
+ section(:jti) { code { id_token.jti } }
+ section(:api_key_role) { link_to id_token.api_key_role.name, profile_oidc_api_key_role_path(id_token.api_key_role.token) }
+ section(:provider) { link_to id_token.provider.issuer, profile_oidc_provider_path(id_token.provider) }
+ section(:claims) { render OIDC::IdToken::KeyValuePairsComponent.new(pairs: id_token.claims) }
+ section(:header) { render OIDC::IdToken::KeyValuePairsComponent.new(pairs: id_token.header) }
+ end
+ end
+
+ private
+
+ def section(header, &)
+ h3(class: "t-list__heading") { id_token.class.human_attribute_name(header) }
+ div(class: "push--s", &)
+ end
+end
diff --git a/app/views/oidc/providers/index_view.rb b/app/views/oidc/providers/index_view.rb
new file mode 100644
index 00000000000..66ae29cd8da
--- /dev/null
+++ b/app/views/oidc/providers/index_view.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class OIDC::Providers::IndexView < ApplicationView
+ include Phlex::Rails::Helpers::LinkTo
+
+ attr_reader :providers
+
+ def initialize(providers:)
+ @providers = providers
+ super()
+ end
+
+ def template
+ self.title = t(".title")
+
+ div(class: "t-body") do
+ p do
+ t(".description_html")
+ end
+ hr
+ header(class: "gems__header push--s") do
+ p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(providers) }
+ end
+ ul do
+ providers.each do |provider|
+ li { link_to provider.issuer, profile_oidc_provider_path(provider) }
+ end
+ end
+ plain helpers.paginate(providers)
+ end
+ end
+end
diff --git a/app/views/oidc/providers/show_view.rb b/app/views/oidc/providers/show_view.rb
new file mode 100644
index 00000000000..09c1999e616
--- /dev/null
+++ b/app/views/oidc/providers/show_view.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class OIDC::Providers::ShowView < ApplicationView
+ include Phlex::Rails::Helpers::LinkTo
+
+ attr_reader :provider
+
+ def initialize(provider:)
+ @provider = provider
+ super()
+ end
+
+ def template
+ self.title = t(".title")
+
+ div(class: "") do
+ dl(class: "t-body provider_attributes") do
+ supported_attrs.each do |attr|
+ val = provider.configuration.send(attr)
+ next if val.blank?
+ dt { provider.configuration.class.human_attribute_name(attr) }
+ dd do
+ attr.end_with?("s_supported") ? tags_attr(attr, val) : text_attr(attr, val)
+ end
+ end
+ end
+
+ div(class: "t-body") do
+ hr
+ h3(class: "t-list__heading") { "Roles" }
+
+ div(class: "") do
+ api_key_roles = helpers.current_user.oidc_api_key_roles.where(provider:).page(0).per(10)
+ header(class: "gems__header push--s") do
+ p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(api_key_roles) }
+ end
+ render OIDC::ApiKeyRole::TableComponent.new(api_key_roles:) if api_key_roles.present?
+ end
+ end
+ end
+ end
+
+ def supported_attrs
+ (provider.configuration.required_attributes + provider.configuration.optional_attributes).map!(&:to_s)
+ end
+
+ def tags_attr(_attr, val)
+ ul(class: "tag-list") do
+ val.each do |t|
+ li { code { t } }
+ end
+ end
+ end
+
+ def text_attr(attr, val)
+ code do
+ case attr
+ when "issuer", /_(uri|endpoint)$/
+ link_to(val, val)
+ else
+ val
+ end
+ end
+ end
+end
diff --git a/app/views/rubygems/_aside.html.erb b/app/views/rubygems/_aside.html.erb
index 7f56c004ab9..1c509a68025 100644
--- a/app/views/rubygems/_aside.html.erb
+++ b/app/views/rubygems/_aside.html.erb
@@ -1,13 +1,10 @@
-
<% if github_data_params = github_params(@rubygem) %>
<%= render partial: "rubygems/github_button", locals: { github_data_params: github_data_params } %>
<% end %>
-
<% if @adoption %>
<%= link_to "adoption", rubygem_adoptions_path(@rubygem), class: "adoption__tag" %>
<% end %>
-
<%= t('stats.index.total_downloads') %>
@@ -18,14 +15,12 @@
<%= number_with_delimiter(@latest_version.downloads_count) %>
-
<%= pluralized_licenses_header @latest_version %>:
<%= formatted_licenses @latest_version.licenses %>
-
<%= t('.required_ruby_version') %>:
@@ -36,7 +31,6 @@
<% end %>
-
<% if @rubygem.metadata_mfa_required? %>
<%= t('.requires_mfa') %>:
@@ -45,7 +39,6 @@
<% end %>
-
<% if @latest_version.rubygems_metadata_mfa_required? %>
<%= t('.released_with_mfa') %>:
@@ -54,16 +47,14 @@
<% end %>
-
<% if @latest_version.required_rubygems_version != '>= 0' %>
<%= t('.required_rubygems_version') %>:
- <%= @latest_version.required_rubygems_version %>
+ <%= @latest_version.required_rubygems_version %>
<% end %>
-
<%= t '.links.header' %>:
<%- @versioned_links.each do |name, link| %>
@@ -76,6 +67,7 @@
<%= report_abuse_link(@rubygem) %>
<%= reverse_dependencies_link(@rubygem) %>
<%= ownership_link(@rubygem) if @rubygem.owned_by?(current_user) %>
+ <%= oidc_api_key_role_links(@rubygem) if @rubygem.owned_by?(current_user) %>
<%= resend_owner_confirmation_link(@rubygem) if @rubygem.unconfirmed_ownership?(current_user) %>
<%= rubygem_adoptions_link(@rubygem) if @rubygem.owned_by?(current_user) || @rubygem.ownership_requestable?%>
diff --git a/app/views/settings/edit.html.erb b/app/views/settings/edit.html.erb
index b49e1e056af..81d239543f9 100644
--- a/app/views/settings/edit.html.erb
+++ b/app/views/settings/edit.html.erb
@@ -69,6 +69,12 @@
<%= link_to t('api_keys.index.api_keys'), profile_api_keys_path %>
+<% if @user.oidc_api_key_roles.any? %>
+
+
<%= link_to t('oidc.api_key_roles.index.api_key_roles'), profile_oidc_api_key_roles_path %>
+
+<% end %>
+
<% if @user.ownerships.any? %>
<%= link_to t("notifiers.show.title"), notifier_path %>
diff --git a/config/application.rb b/config/application.rb
index 086b5afc3a9..d4b6d24bfb7 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -55,6 +55,10 @@ class Application < Rails::Application
config.toxic_domains_filepath = Rails.root.join("vendor", "toxic_domains_whole.txt")
config.active_job.queue_adapter = :good_job
+
+ config.autoload_paths << "#{root}/app/views"
+ config.autoload_paths << "#{root}/app/views/layouts"
+ config.autoload_paths << "#{root}/app/views/components"
end
def self.config
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index fa1ea084191..ab7ee81ab1a 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -1,5 +1,85 @@
{
"ignored_warnings": [
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "076f564d79f0e732d93925ac722bf7276e121d6f96827c1e651540ca91fd1153",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/controllers/oidc/id_tokens_controller.rb",
+ "line": 17,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => OIDC::IdTokens::IndexView.new(:id_tokens => current_user.oidc_id_tokens.includes(:api_key, :api_key_role, :provider).page(params[:page].to_i).strict_loading), {})",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "OIDC::IdTokensController",
+ "method": "index"
+ },
+ "user_input": "params[:page]",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
+ },
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "0961cc0a168ef86428d84889a7092c841454aadea80c0294f6703bac98307444",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/views/oidc/api_key_roles/show.html.erb",
+ "line": 20,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => current_user.oidc_api_key_roles.includes(:provider).find_by!(:token => params.require(:token)).access_policy, {})",
+ "render_path": [
+ {
+ "type": "controller",
+ "class": "OIDC::ApiKeyRolesController",
+ "method": "show",
+ "line": 27,
+ "file": "app/controllers/oidc/api_key_roles_controller.rb",
+ "rendered": {
+ "name": "oidc/api_key_roles/show",
+ "file": "app/views/oidc/api_key_roles/show.html.erb"
+ }
+ }
+ ],
+ "location": {
+ "type": "template",
+ "template": "oidc/api_key_roles/show"
+ },
+ "user_input": "params.require(:token)",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
+ },
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "7395657e2ba2c741584d62f34f8528f5c909c8ba67fe3b7d653643ab1bb40079",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/controllers/oidc/api_key_roles_controller.rb",
+ "line": 32,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => OIDC::ApiKeyRoles::GitHubActionsWorkflowView.new(:api_key_role => current_user.oidc_api_key_roles.includes(:provider).find_by!(:token => params.require(:token))), {})",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "OIDC::ApiKeyRolesController",
+ "method": "github_actions_workflow"
+ },
+ "user_input": "params.require(:token)",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
+ },
{
"warning_type": "Mass Assignment",
"warning_code": 105,
@@ -7,7 +87,7 @@
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/hook_relay_controller.rb",
- "line": 23,
+ "line": 19,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:attempts, :account_id, :hook_id, :id, :max_attempts, :status, :stream, :failure_reason, :completed_at, :created_at, :request => ([:target_url]))",
"render_path": null,
@@ -22,8 +102,88 @@
915
],
"note": "account_id is used to validate that the request indeed comes from hook relay"
+ },
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "ab59d54552d08258896fd31f8f4342971b02124008afe35a1507a5d1eef438b6",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/controllers/oidc/providers_controller.rb",
+ "line": 13,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => OIDC::Providers::IndexView.new(:providers => OIDC::Provider.all.strict_loading.page(params[:page].to_i)), {})",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "OIDC::ProvidersController",
+ "method": "index"
+ },
+ "user_input": "params[:page]",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
+ },
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "f23085c93323ec923578bed895c153b25b5bc2e9b64687c05ce426da16e6c755",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/views/oidc/api_key_roles/show.html.erb",
+ "line": 16,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => current_user.oidc_api_key_roles.includes(:provider).find_by!(:token => params.require(:token)).api_key_permissions, {})",
+ "render_path": [
+ {
+ "type": "controller",
+ "class": "OIDC::ApiKeyRolesController",
+ "method": "show",
+ "line": 27,
+ "file": "app/controllers/oidc/api_key_roles_controller.rb",
+ "rendered": {
+ "name": "oidc/api_key_roles/show",
+ "file": "app/views/oidc/api_key_roles/show.html.erb"
+ }
+ }
+ ],
+ "location": {
+ "type": "template",
+ "template": "oidc/api_key_roles/show"
+ },
+ "user_input": "params.require(:token)",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
+ },
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "fb6ebb2f4b1a58b85cf0e01691f09c395754282fdfe576750538fc3dc62c57b2",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/controllers/oidc/providers_controller.rb",
+ "line": 17,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => OIDC::Providers::ShowView.new(:provider => OIDC::Provider.find(params.require(:id))), {})",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "OIDC::ProvidersController",
+ "method": "show"
+ },
+ "user_input": "params.require(:id)",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
}
],
- "updated": "2023-03-01 02:46:07 -0800",
- "brakeman_version": "5.4.1"
+ "updated": "2023-10-17 10:36:55 -0700",
+ "brakeman_version": "6.0.1"
}
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 8b2cfaf34da..1dbb17d7337 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -50,6 +50,13 @@ de:
full_name: Vollständiger Name
handle: Benutzername
password: Passwort
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn:
@@ -69,12 +76,18 @@ de:
models:
user:
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion:
+ gems:
+ too_long:
api_keys:
create:
success:
@@ -562,6 +575,10 @@ de:
wiki: Wiki
resend_ownership_confirmation:
ownership:
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace:
dependencies:
@@ -715,3 +732,55 @@ de:
new_device:
nickname:
submit:
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 76a43b7ed79..80ffe4218ee 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -59,6 +59,13 @@ en:
full_name: Full name
handle: Username
password: Password
+ api_key:
+ oidc_api_key_role: OIDC API Key Role
+ oidc/id_token:
+ jti: JWT ID
+ api_key_role: API Key Role
+ oidc/api_key_role:
+ api_key_permissions: API Key Permissions
errors:
messages:
unpwn: has previously appeared in a data breach and should not be used
@@ -78,12 +85,18 @@ en:
models:
user: User
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri: JWKS URI
+ id_token_signing_alg_values_supported: ID Token signing algorithms supported
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion: "%{value} seconds must be between 5 minutes (300 seconds) and 1 day (86,400 seconds)"
+ gems:
+ too_long: "may include at most 1 gem"
api_keys:
create:
success: "Created new API key"
@@ -561,6 +574,10 @@ en:
wiki: Wiki
resend_ownership_confirmation: Resend confirmation
ownership: Ownership
+ oidc:
+ api_key_role:
+ name: "OIDC: %{name}"
+ new: "OIDC: Create"
reserved:
reserved_namespace: This namespace is reserved by rubygems.org.
dependencies:
@@ -714,3 +731,55 @@ en:
new_device: Register a new security device
nickname: Nickname
submit: Register device
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles: OIDC API Key Roles
+ new_role: Create API Key Role
+ show:
+ api_key_role_name: "API Key Role %{name}"
+ automate_gh_actions_publishing: "Automate Gem Publishing with GitHub Actions"
+ view_provider: "View provider %{issuer}"
+ edit_role: "Edit API Key Role"
+ git_hub_actions_workflow:
+ title: "OIDC Gem Push GitHub Actions Workflow"
+ configured_for_html: "This OIDC API Key Role is configured to allow pushing %{link_html} from GitHub Actions."
+ to_automate_html: "To automate releasing %{link_html} when a new tag is pushed, add the following workflow to your repository."
+ not_github: "This OIDC API Key Role is not configured for GitHub Actions."
+ not_push: "This OIDC API Key Role is not configured to allow pushing gems."
+ copy_to_clipboard: Copy to clipboard
+ copied: Copied!
+ new:
+ title: "New OIDC API Key Role"
+ update:
+ success: "OIDC API Key Role updated"
+ edit_role: "Edit API Key Role"
+ form:
+ add_condition: Add condition
+ remove_condition: Remove condition
+ add_statement: Add statement
+ remove_statement: Remove statement
+ providers:
+ index:
+ title: "OIDC Providers"
+ description_html: "These are the OIDC providers that have been configured for RubyGems.org.
Please reach out to support if you need another OIDC Provider added."
+ show:
+ title: "OIDC Provider"
+ id_tokens:
+ index:
+ title: "OIDC ID Tokens"
+ show:
+ title: "OIDC ID Token"
+ duration:
+ minutes:
+ other: "%{count} minutes"
+ one: "1 minute"
+ hours:
+ other: "%{count} hours"
+ one: "1 hour"
+ days:
+ other: "%{count} days"
+ one: "1 day"
+ seconds:
+ other: "%{count} seconds"
+ one: "1 second"
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 12ed44f39d3..1361bbef11a 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -61,6 +61,13 @@ es:
full_name: Nombre completo
handle: Usuario
password: Contraseña
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn:
@@ -80,12 +87,18 @@ es:
models:
user:
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion:
+ gems:
+ too_long:
api_keys:
create:
success:
@@ -592,6 +605,10 @@ es:
wiki: Wiki
resend_ownership_confirmation:
ownership:
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace: Este namespace está reservado por rubygems.org.
dependencies:
@@ -759,3 +776,55 @@ es:
new_device:
nickname:
submit:
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 696929d89ce..a64bf5f2601 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -61,6 +61,13 @@ fr:
full_name: Nom complet
handle: Pseudonyme
password: Mot de passe
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn:
@@ -80,12 +87,18 @@ fr:
models:
user:
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion:
+ gems:
+ too_long:
api_keys:
create:
success:
@@ -599,6 +612,10 @@ fr:
wiki: Wiki
resend_ownership_confirmation:
ownership:
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace: Ce nom est réservé par rubygems.org.
dependencies:
@@ -765,3 +782,55 @@ fr:
new_device:
nickname:
submit:
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 66976e36499..3f7708a3a91 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -54,6 +54,13 @@ ja:
full_name: フルネーム
handle: ユーザー名
password: パスワード
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn: 過去にデータ侵害を受けたためお使いになれません
@@ -73,12 +80,18 @@ ja:
models:
user: ユーザー
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion: "%{value}秒は5分(300秒)から1日(86,400秒)までの間でなければなりません"
+ gems:
+ too_long:
api_keys:
create:
success: 新しいAPIキーを作成しました
@@ -565,6 +578,10 @@ ja:
wiki: Wiki
resend_ownership_confirmation: 確認を再送
ownership: 所有者
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace: この名前空間はrubygems.orgにより予約されています。
dependencies:
@@ -724,3 +741,55 @@ ja:
new_device: 新しいセキュリティ機器を登録
nickname: ニックネーム
submit: 機器を登録
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 17d7b71425d..49fc0eaeba1 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -53,6 +53,13 @@ nl:
full_name: Voor-en achternaam
handle: Gebruikersnaam
password: Wachtwoord
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn:
@@ -72,12 +79,18 @@ nl:
models:
user:
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion:
+ gems:
+ too_long:
api_keys:
create:
success:
@@ -566,6 +579,10 @@ nl:
wiki: Wiki
resend_ownership_confirmation:
ownership:
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace:
dependencies:
@@ -719,3 +736,55 @@ nl:
new_device:
nickname:
submit:
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml
index 5bb974ba689..27d3b2a147d 100644
--- a/config/locales/pt-BR.yml
+++ b/config/locales/pt-BR.yml
@@ -60,6 +60,13 @@ pt-BR:
full_name: Nome completo
handle: Usuário
password: Senha
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn: já apareceu anteriormente em um vazamento de dados e não deve ser utilizada
@@ -79,12 +86,18 @@ pt-BR:
models:
user: Usuário
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion:
+ gems:
+ too_long:
api_keys:
create:
success:
@@ -577,6 +590,10 @@ pt-BR:
wiki: Wiki
resend_ownership_confirmation:
ownership:
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace: This namespace is reserved by rubygems.org.
dependencies:
@@ -742,3 +759,55 @@ pt-BR:
new_device:
nickname:
submit:
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 263a0147095..62bef6f4901 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -55,6 +55,13 @@ zh-CN:
full_name: 全名
handle: 用户名
password: 密码
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn: 曾出现过数据泄露,不应该再使用
@@ -74,12 +81,18 @@ zh-CN:
models:
user: 用户
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion:
+ gems:
+ too_long:
api_keys:
create:
success: 新的 API 密钥已创建
@@ -573,6 +586,10 @@ zh-CN:
wiki: Wiki
resend_ownership_confirmation: 重新发送
ownership: 所有权
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace: 该命名空间由 RubyGems.org 保留。
dependencies:
@@ -732,3 +749,55 @@ zh-CN:
new_device: 创建一个新的安全设备
nickname: 昵称
submit: 注册设备
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index 63cd0ab7d4b..40526258b0b 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -50,6 +50,13 @@ zh-TW:
full_name: 全名
handle: 帳號
password: 密碼
+ api_key:
+ oidc_api_key_role:
+ oidc/id_token:
+ jti:
+ api_key_role:
+ oidc/api_key_role:
+ api_key_permissions:
errors:
messages:
unpwn:
@@ -69,12 +76,18 @@ zh-TW:
models:
user:
activemodel:
+ attributes:
+ oidc/provider/configuration:
+ jwks_uri:
+ id_token_signing_alg_values_supported:
errors:
models:
oidc/api_key_permissions:
attributes:
valid_for:
inclusion:
+ gems:
+ too_long:
api_keys:
create:
success:
@@ -549,6 +562,10 @@ zh-TW:
wiki: Wiki
resend_ownership_confirmation:
ownership:
+ oidc:
+ api_key_role:
+ name:
+ new:
reserved:
reserved_namespace:
dependencies:
@@ -702,3 +719,55 @@ zh-TW:
new_device:
nickname:
submit:
+ oidc:
+ api_key_roles:
+ index:
+ api_key_roles:
+ new_role:
+ show:
+ api_key_role_name:
+ automate_gh_actions_publishing:
+ view_provider:
+ edit_role:
+ git_hub_actions_workflow:
+ title:
+ configured_for_html:
+ to_automate_html:
+ not_github:
+ not_push:
+ copy_to_clipboard:
+ copied:
+ new:
+ title:
+ update:
+ success:
+ edit_role:
+ form:
+ add_condition:
+ remove_condition:
+ add_statement:
+ remove_statement:
+ providers:
+ index:
+ title:
+ description_html:
+ show:
+ title:
+ id_tokens:
+ index:
+ title:
+ show:
+ title:
+ duration:
+ minutes:
+ other:
+ one:
+ hours:
+ other:
+ one:
+ days:
+ other:
+ one:
+ seconds:
+ other:
+ one:
diff --git a/config/routes.rb b/config/routes.rb
index d524fb98dd9..d1bca3521ef 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -172,6 +172,17 @@
resources :api_keys do
delete :reset, on: :collection
end
+
+ namespace :oidc do
+ resources :api_key_roles, param: :token, except: %i[destroy] do
+ member do
+ get 'github_actions_workflow'
+ end
+ end
+ resources :api_key_roles, param: :token, only: %i[show], constraints: { format: :json }
+ resources :id_tokens, only: %i[index show]
+ resources :providers, only: %i[index show]
+ end
end
resources :stats, only: :index
get "/news" => 'news#show', as: 'legacy_news_path'
diff --git a/db/seeds.rb b/db/seeds.rb
index cf55d49955a..539ac0c49a3 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -61,7 +61,8 @@
Version.create_with(
indexed: true,
- pusher: author
+ pusher: author,
+ metadata: { "source_code_uri" => "https://github.com/example/#{rubygem1.name}" }
).find_or_create_by!(rubygem: rubygem0, number: "1.0.0", platform: "ruby", gem_platform: "ruby")
Version.create_with(
indexed: true
@@ -161,14 +162,18 @@
github_oidc_provider = OIDC::Provider
.create_with(
configuration: {
- issuer: "https://token.actions.githubusercontent.com",
- jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks",
+ issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER,
+ jwks_uri: "#{OIDC::Provider::GITHUB_ACTIONS_ISSUER}/.well-known/jwks",
+ subject_types_supported: %w[public pairwise],
response_types_supported: ["id_token"],
- subject_types_supported: ["public"],
+ claims_supported: %w[sub aud exp iat iss jti nbf ref repository repository_id repository_owner repository_owner_id
+ run_id run_number run_attempt actor actor_id workflow workflow_ref workflow_sha head_ref
+ base_ref event_name ref_type environment environment_node_id job_workflow_ref
+ job_workflow_sha repository_visibility runner_environment],
id_token_signing_alg_values_supported: ["RS256"],
- claims_supported: ["repo"]
+ scopes_supported: ["openid"]
}
- ).find_or_create_by!(issuer: "https://token.actions.githubusercontent.com")
+ ).find_or_create_by!(issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER)
author_oidc_api_key_role = author.oidc_api_key_roles.create_with(
api_key_permissions: {
@@ -184,7 +189,7 @@
},
conditions: [{
operator: "string_equals",
- claim: "repo",
+ claim: "repository",
value: "rubygems/rubygem0"
}],
]
@@ -202,8 +207,47 @@
name: "push-rubygem-1-expired",
).tap do |api_key|
OIDC::IdToken.find_or_create_by!(
- api_key:,
- jwt: { claims: {jti: "expired"}, header: {}},
+ api_key:,
+ jwt: {
+ claims: {
+ aud: "https://oidc-api-token.rubygems.org",
+ exp: 1_692_643_030,
+ iat: 1_692_642_730,
+ iss: "https://token.actions.githubusercontent.com",
+ jti: "42b0b56e-ff54-4ed5-bd87-448af14176f1",
+ nbf: 1_692_642_130,
+ ref: "refs/heads/main",
+ sha: "a39b8e11d7804422b7ff4924b246492fd366ea6c",
+ sub: "repo:segiddins/oidc-test:ref:refs/heads/main",
+ actor: "segiddins",
+ run_id: "5930133091",
+ actor_id: "1946610",
+ base_ref: "",
+ head_ref: "",
+ ref_type: "branch",
+ workflow: ".github/workflows/token.yml",
+ event_name: "push",
+ repository: "segiddins/oidc-test",
+ run_number: "33",
+ run_attempt: "1",
+ workflow_ref: "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main",
+ workflow_sha: "a39b8e11d7804422b7ff4924b246492fd366ea6c",
+ ref_protected: "false",
+ repository_id: "620393838",
+ job_workflow_ref: "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main",
+ job_workflow_sha: "a39b8e11d7804422b7ff4924b246492fd366ea6c",
+ repository_owner: "segiddins",
+ runner_environment: "github-hosted",
+ repository_owner_id: "1946610",
+ repository_visibility: "public"
+ },
+ header: {
+ alg: "RS256",
+ kid: "78167F727DEC5D801DD1C8784C704A1C880EC0E1",
+ typ: "JWT",
+ x5t: "eBZ_cn3sXYAd0ch4THBKHIgOwOE"
+ }
+ },
api_key_role: author_oidc_api_key_role
)
api_key.touch(:expires_at, time: "2020-01-01T00:00:00Z")
@@ -218,8 +262,8 @@
name: "push-rubygem-1-unexpired",
).tap do |api_key|
OIDC::IdToken.find_or_create_by!(
- api_key:,
- jwt: { claims: {jti: "unexpired"}, header: {}},
+ api_key:,
+ jwt: { claims: { jti: "unexpired" }, header: {} },
api_key_role: author_oidc_api_key_role
)
end
diff --git a/test/factories/oidc/api_key_role.rb b/test/factories/oidc/api_key_role.rb
index 8af57b7d94b..97d9615ddfa 100644
--- a/test/factories/oidc/api_key_role.rb
+++ b/test/factories/oidc/api_key_role.rb
@@ -7,7 +7,7 @@
scopes: ["push_rubygem"]
}
end
- name { "GitHub Pusher" }
+ sequence(:name) { |n| "GitHub Pusher #{n}" }
access_policy do
{
statements: [
diff --git a/test/factories/oidc/id_token.rb b/test/factories/oidc/id_token.rb
index 9746b76ffd1..6e222911a9b 100644
--- a/test/factories/oidc/id_token.rb
+++ b/test/factories/oidc/id_token.rb
@@ -1,7 +1,7 @@
FactoryBot.define do
factory :oidc_id_token, class: "OIDC::IdToken" do
api_key_role factory: :oidc_api_key_role
- api_key { association :api_key, user: api_key_role.user, key: SecureRandom.hex(20) }
+ api_key { association :api_key, key: SecureRandom.hex(20), **api_key_role.api_key_permissions.create_params(api_key_role.user) }
jwt do
{
claims: {
diff --git a/test/functional/oidc/api_key_roles_controller_test.rb b/test/functional/oidc/api_key_roles_controller_test.rb
new file mode 100644
index 00000000000..9f37a802079
--- /dev/null
+++ b/test/functional/oidc/api_key_roles_controller_test.rb
@@ -0,0 +1,61 @@
+require "test_helper"
+
+class OIDC::ApiKeyRolesControllerTest < ActionController::TestCase
+ context "when not logged in" do
+ setup { @user = create(:user) }
+
+ context "on GET to index" do
+ setup { get :index }
+
+ should redirect_to("sign in") { sign_in_path }
+ end
+ end
+
+ context "when logged in" do
+ setup do
+ @user = create(:user)
+ @api_key_role = create(:oidc_api_key_role, user: @user)
+ @id_token = create(:oidc_id_token, api_key_role: @api_key_role)
+ sign_in_as(@user)
+ end
+
+ context "with a password session" do
+ setup do
+ session[:verification] = 10.minutes.from_now
+ session[:verified_user] = @user.id
+ end
+
+ context "on GET to index" do
+ setup { get :index }
+ should respond_with :success
+ end
+
+ context "on GET to show with id" do
+ setup { get :show, params: { token: @api_key_role.token } }
+ should respond_with :success
+ end
+
+ context "on GET to show with nonexistent id" do
+ setup { get :show, params: { token: "DNE" } }
+ should respond_with :not_found
+ end
+ end
+
+ context "without a password session" do
+ context "on GET to index" do
+ setup { get :index }
+ should redirect_to("verify session") { verify_session_path }
+ end
+
+ context "on GET to show with id" do
+ setup { get :show, params: { token: @api_key_role.token } }
+ should redirect_to("verify session") { verify_session_path }
+ end
+
+ context "on GET to show with nonexistent id" do
+ setup { get :show, params: { token: "DNE" } }
+ should redirect_to("verify session") { verify_session_path }
+ end
+ end
+ end
+end
diff --git a/test/integration/api/v1/oidc/api_key_roles_test.rb b/test/integration/api/v1/oidc/api_key_roles_test.rb
index 73a1dd82236..689943a2a28 100644
--- a/test/integration/api/v1/oidc/api_key_roles_test.rb
+++ b/test/integration/api/v1/oidc/api_key_roles_test.rb
@@ -24,7 +24,7 @@ class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest
"oidc_provider_id" => @role.oidc_provider_id,
"user_id" => @user.id,
"api_key_permissions" => { "scopes" => ["push_rubygem"], "valid_for" => 1800, "gems" => nil },
- "name" => "GitHub Pusher",
+ "name" => @role.name,
"access_policy" => { "statements" => [
{ "effect" => "allow",
"principal" => { "oidc" => @role.provider.issuer },
@@ -252,7 +252,7 @@ def jwt(claims = @claims, key: @pkey)
assert_match(/^rubygems_/, resp["rubygems_api_key"])
assert_equal({
"rubygems_api_key" => resp["rubygems_api_key"],
- "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d",
+ "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d",
"scopes" => ["push_rubygem"],
"expires_at" => 30.minutes.from_now
}, resp)
@@ -281,7 +281,7 @@ def jwt(claims = @claims, key: @pkey)
assert_match(/^rubygems_/, resp["rubygems_api_key"])
assert_equal({
"rubygems_api_key" => resp["rubygems_api_key"],
- "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d",
+ "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d",
"scopes" => ["push_rubygem"],
"expires_at" => 30.minutes.from_now,
"gem" => Rubygem.find_by!(name: gem_name).as_json
@@ -327,7 +327,7 @@ def jwt(claims = @claims, key: @pkey)
assert_match(/^rubygems_/, resp["rubygems_api_key"])
assert_equal({
"rubygems_api_key" => resp["rubygems_api_key"],
- "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d",
+ "name" => "#{@role.name}-79685b65-945d-450a-a3d8-a36bcf72c23d",
"scopes" => ["push_rubygem"],
"expires_at" => 30.minutes.from_now
}, resp)
diff --git a/test/integration/oidc/api_key_roles_controller_test.rb b/test/integration/oidc/api_key_roles_controller_test.rb
new file mode 100644
index 00000000000..121bf9022de
--- /dev/null
+++ b/test/integration/oidc/api_key_roles_controller_test.rb
@@ -0,0 +1,80 @@
+require "test_helper"
+
+class OIDC::ApiKeyRolesControllerIntegrationTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now)
+ post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD })
+
+ @id_token = create(:oidc_id_token, user: @user)
+ @api_key_role = @id_token.api_key_role
+ end
+
+ context "with a verified session" do
+ setup do
+ post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD }))
+ end
+
+ should "get show" do
+ get profile_oidc_api_key_role_url(@api_key_role.token)
+
+ assert_response :success
+ end
+
+ should "get index" do
+ get profile_oidc_api_key_roles_url
+
+ assert_response :success
+ end
+
+ should "get github_actions_workflow" do
+ get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token)
+
+ assert_response :success
+ end
+
+ should "get github_actions_workflow with a github actions role" do
+ provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com")
+ @api_key_role = create(:oidc_api_key_role, provider:, user: @user).token
+ get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role)
+
+ assert_response :success
+ end
+
+ should "get github_actions_workflow with a github actions role scoped to a gem" do
+ provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com")
+ rubygem = create(:rubygem, owners: [@user])
+ create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/#{rubygem.name}" })
+
+ @api_key_role = create(:oidc_api_key_role,
+ user: @user,
+ provider:,
+ api_key_permissions: { scopes: ["push_rubygem"], gems: [rubygem.name] })
+ get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token)
+
+ assert_response :success
+ end
+ end
+
+ context "without a verified session" do
+ should "redirect show to verify" do
+ get profile_oidc_api_key_role_url(@api_key_role.token)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect index to verify" do
+ get profile_oidc_api_key_roles_url
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect github_actions_workflow to verify" do
+ get github_actions_workflow_profile_oidc_api_key_role_url(@api_key_role.token)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+ end
+end
diff --git a/test/integration/oidc/id_tokens_controller_test.rb b/test/integration/oidc/id_tokens_controller_test.rb
new file mode 100644
index 00000000000..410d4a78b7c
--- /dev/null
+++ b/test/integration/oidc/id_tokens_controller_test.rb
@@ -0,0 +1,44 @@
+require "test_helper"
+
+class OIDC::IdTokensControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now)
+ post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD })
+
+ @id_token = create(:oidc_id_token, user: @user)
+ end
+
+ context "with a verified session" do
+ setup do
+ post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD }))
+ end
+
+ should "get show" do
+ get profile_oidc_id_token_url(@id_token)
+
+ assert_response :success
+ end
+
+ should "get index" do
+ get profile_oidc_id_tokens_url
+
+ assert_response :success
+ end
+ end
+
+ context "without a verified session" do
+ should "redirect show to verify" do
+ get profile_oidc_id_token_url(@id_token)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect index to verify" do
+ get profile_oidc_id_tokens_url
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+ end
+end
diff --git a/test/integration/oidc/providers_controller_test.rb b/test/integration/oidc/providers_controller_test.rb
new file mode 100644
index 00000000000..fb0fd6b35f3
--- /dev/null
+++ b/test/integration/oidc/providers_controller_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class OIDC::ProvidersControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = create(:user, remember_token_expires_at: Gemcutter::REMEMBER_FOR.from_now)
+ post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD })
+
+ @id_token = create(:oidc_id_token, user: @user)
+ @provider = @id_token.provider
+ end
+
+ context "with a verified session" do
+ setup do
+ post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD }))
+ end
+
+ should "get show" do
+ get profile_oidc_provider_url(@provider)
+
+ assert_response :success
+ end
+
+ should "get index" do
+ get profile_oidc_providers_url
+
+ assert_response :success
+ end
+ end
+
+ context "without a verified session" do
+ should "redirect show to verify" do
+ get profile_oidc_provider_url(@provider)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect index to verify" do
+ get profile_oidc_providers_url
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+ end
+end
diff --git a/test/models/oidc/api_key_permissions_test.rb b/test/models/oidc/api_key_permissions_test.rb
index 2d1b36cf0b7..6299861c8c9 100644
--- a/test/models/oidc/api_key_permissions_test.rb
+++ b/test/models/oidc/api_key_permissions_test.rb
@@ -24,6 +24,6 @@ class OIDC::ApiKeyPermissionsTest < ActiveSupport::TestCase
permissions = OIDC::ApiKeyPermissions.new(gems: %w[a b])
permissions.validate
- assert_equal ["is too long (maximum is 1 character)"], permissions.errors.messages[:gems]
+ assert_equal ["may include at most 1 gem"], permissions.errors.messages[:gems]
end
end
diff --git a/test/models/oidc/api_key_role_test.rb b/test/models/oidc/api_key_role_test.rb
index a25dad9083c..d2ffe123eff 100644
--- a/test/models/oidc/api_key_role_test.rb
+++ b/test/models/oidc/api_key_role_test.rb
@@ -45,6 +45,7 @@ class OIDC::ApiKeyRoleTest < ActiveSupport::TestCase
@role.access_policy.statements = [OIDC::AccessPolicy::Statement.new(
principal: { oidc: nil }
)]
+ @role.provider = nil
@role.validate
assert_equal ["can't be blank"], @role.errors.messages[:"access_policy.statements[0].principal.oidc"]