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 %>
+
<%= 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