diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 301e7791822..c59aeef03e5 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.4
-ARG RUBY_VERSION=3.2
+ARG RUBY_VERSION=3.3
FROM ruby:${RUBY_VERSION}-alpine
diff --git a/.github/actions/setup-rubygems.org/action.yml b/.github/actions/setup-rubygems.org/action.yml
index dbd26c1b503..a18c2e27bfd 100644
--- a/.github/actions/setup-rubygems.org/action.yml
+++ b/.github/actions/setup-rubygems.org/action.yml
@@ -18,20 +18,14 @@ runs:
shell: bash
run: |
timeout 300 bash -c "until curl --silent --output /dev/null http://localhost:9200/_cat/health?h=st; do printf '.'; sleep 5; done; printf '\n'"
- - uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011 # v1.157.0
+ - uses: ruby/setup-ruby@360dc864d5da99d54fcb8e9148c14a84b90d3e88 # v1.165.1
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true
- - name: set rubygems version
+ rubygems: ${{ inputs.rubygems-version }}
+ - name: Print bundle environment
shell: bash
- run: |
- if [ "${{ inputs.rubygems-version }}" != "latest" ]; then
- gem update --system ${{ inputs.rubygems-version }};
- else
- gem update --system
- fi
- gem --version
- bundle --version
+ run: bundle env
- name: Prepare environment
shell: bash
run: |
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 9dcafb91b2f..b0df8b8be59 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -14,8 +14,8 @@ jobs:
name: Docker build (and optional push)
runs-on: ubuntu-22.04
env:
- RUBYGEMS_VERSION: 3.4.21
- RUBY_VERSION: 3.2.2
+ RUBYGEMS_VERSION: 3.5.4
+ RUBY_VERSION: 3.3.0
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Docker Buildx
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 026d6fbc7c5..d9d832b5a25 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- - uses: ruby/setup-ruby@036ef458ddccddb148a2b9fb67e95a22fdbf728b # v1.160.0
+ - uses: ruby/setup-ruby@360dc864d5da99d54fcb8e9148c14a84b90d3e88 # v1.165.1
with:
bundler-cache: true
- name: Rubocop
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- - uses: ruby/setup-ruby@036ef458ddccddb148a2b9fb67e95a22fdbf728b # v1.160.0
+ - uses: ruby/setup-ruby@360dc864d5da99d54fcb8e9148c14a84b90d3e88 # v1.165.1
with:
bundler-cache: true
- name: Brakeman
@@ -41,7 +41,7 @@ jobs:
- name: login to Github Packages
run: echo "${{ github.token }}" | docker login https://ghcr.io -u ${GITHUB_ACTOR} --password-stdin
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- - uses: ruby/setup-ruby@036ef458ddccddb148a2b9fb67e95a22fdbf728b # v1.160.0
+ - uses: ruby/setup-ruby@360dc864d5da99d54fcb8e9148c14a84b90d3e88 # v1.165.1
with:
bundler-cache: true
- name: krane render
@@ -50,7 +50,7 @@ jobs:
env:
ENVIRONMENT: "${{ matrix.environment }}"
REVISION: "${{ github.sha }}"
- - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
+ - uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0
with:
name: "${{ matrix.environment }}.rendered.yaml"
path: "config/deploy/${{ matrix.environment }}.rendered.yaml"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6a86ea77ab4..33c822e314a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -26,16 +26,16 @@ jobs:
matrix:
rubygems:
- name: locked
- version: "3.4.21"
+ version: "3.5.4"
- name: latest
version: latest
- ruby_version: ["3.2.2"]
+ ruby_version: ["3.3.0"]
tests:
- name: general
command: test
- name: system
command: test:system
- name: Rails tests ${{ matrix.tests.name }} (RubyGems ${{ matrix.rubygems.name }})
+ name: Rails tests ${{ matrix.tests.name }} (RubyGems ${{ matrix.rubygems.name }}, Ruby ${{ matrix.ruby_version }})
runs-on: ubuntu-22.04
env:
RUBYGEMS_VERSION: ${{ matrix.rubygems.version }}
@@ -56,7 +56,7 @@ jobs:
- name: Save capybara screenshots
if: ${{ failure() && steps.test-all.outcome == 'failure' }}
- uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
+ uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0
with:
name: capybara-screenshots
path: tmp/capybara
diff --git a/.rubocop.yml b/.rubocop.yml
index 32917f9d948..1f1942788ed 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -13,7 +13,7 @@ AllCops:
- !ruby/regexp /(vendor|bundle|bin|db|tmp|server)\/.*/
DisplayCopNames: true
DisplayStyleGuide: true
- TargetRubyVersion: 3.2
+ TargetRubyVersion: 3.3
NewCops: enable
Rails:
@@ -70,7 +70,7 @@ Metrics/BlockLength:
- config/environments/development.rb
Metrics/ClassLength:
- Max: 356 # TODO: Lower to 100
+ Max: 357 # TODO: Lower to 100
Exclude:
- test/**/*
diff --git a/.ruby-version b/.ruby-version
index be94e6f53db..15a27998172 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.2.2
+3.3.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1a6f2fe06e7..cc927182591 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -90,11 +90,11 @@ Follow the instructions below on how to install Bundler and setup the database.
* Note that `-e "xpack.security.enabled=false"` disables authentication.
-* Install PostgreSQL (>= 11.13.x): `brew install postgres`
+* Install PostgreSQL (>= 12.x): `brew install postgres`
* Setup information: `brew info postgresql`
* Install memcached: `brew install memcached`
* Show all memcached options: `memcached -h`
-* Install Google-Chrome: `brew cask install google-chrome`
+* Install Google-Chrome: `brew install google-chrome --cask`
#### Environment (Linux - Debian/Ubuntu)
@@ -114,10 +114,10 @@ Follow the instructions below on how to install Bundler and setup the database.
### Installing ruby, gem dependencies, and setting up the database
-* Use Ruby 3.2.x
+* Use Ruby 3.3.x
* See: [Ruby install instructions](https://www.ruby-lang.org/en/downloads/).
* `.ruby-version` is present and can be used.
-* Use Rubygems 3.3.x
+* Use Rubygems 3.5.x
* Install bundler:
`gem install bundler`
* Install dependencies and setup the database:
diff --git a/Dockerfile b/Dockerfile
index 79d07e43c5d..832b8a07710 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1.4
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
-ARG RUBY_VERSION=3.2.2
+ARG RUBY_VERSION=3.3.0
ARG ALPINE_VERSION=3.18
FROM ruby:$RUBY_VERSION-alpine${ALPINE_VERSION} as base
@@ -25,11 +25,7 @@ ENV BUNDLE_APP_CONFIG=".bundle_app_config"
# Update rubygems
ARG RUBYGEMS_VERSION
-RUN gem update --system ${RUBYGEMS_VERSION} --no-document && \
- # rubygems-update is completely unused after the `gem update --system` process
- gem uninstall rubygems-update -x && \
- # Remove rubygems cache files, they are unused
- rm -r /usr/local/bundle/cache/ /root/.local/share/gem/
+RUN gem update --system ${RUBYGEMS_VERSION} --no-document
# Throw-away build stage to reduce size of final image
FROM base as build
diff --git a/Gemfile b/Gemfile
index 04ece0f3847..93282f4a537 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,77 +1,84 @@
source "https://rubygems.org"
+# Former default gems
+gem "bigdecimal", "~> 3.1" # activesupport-7.0.8
+gem "mutex_m", "~> 0.2.0" # activesupport-7.0.8
+gem "net-smtp", "~> 0.4.0" # mail-2.8.1
+gem "csv", "~> 3.2" # zeitwerk-2.6.12
+gem "observer", "~> 0.1.2" # launchdarkly-server-sdk-8.0.0
+
gem "rails", "~> 7.0.0"
gem "rails-i18n", "~> 7.0"
-gem "aws-sdk-s3", "~> 1.119"
-gem "aws-sdk-sqs", "~> 1.67"
+gem "aws-sdk-s3", "~> 1.142"
+gem "aws-sdk-sqs", "~> 1.69"
gem "bootsnap", "~> 1.16"
gem "clearance", "~> 2.6"
gem "dalli", "~> 3.2"
-gem "ddtrace", "~> 1.10", require: "ddtrace/auto_instrument"
+gem "ddtrace", "~> 1.18", require: "ddtrace/auto_instrument"
gem "dogstatsd-ruby", "~> 5.5"
-gem "google-protobuf", "~> 3.22"
-gem "faraday", "~> 1.10"
-gem "good_job", "~> 3.17"
+gem "google-protobuf", "~> 3.25"
+gem "faraday", "~> 2.8"
+gem "good_job", "~> 3.22"
gem "gravtastic", "~> 3.2"
gem "high_voltage", "~> 3.1"
-gem "honeybadger", "~> 5.2"
+gem "honeybadger", "~> 5.4"
gem "http_accept_language", "~> 2.1"
gem "jquery-rails", "~> 4.5"
gem "kaminari", "~> 1.2"
-gem "launchdarkly-server-sdk", "~> 8.0"
+gem "launchdarkly-server-sdk", "~> 8.1"
gem "mail", "~> 2.8"
gem "octokit", "~> 8.0"
gem "omniauth-github", "~> 2.0"
gem "omniauth", "~> 2.1"
gem "omniauth-rails_csrf_protection", "~> 1.0"
-gem "openid_connect", "~> 1.4"
+gem "openid_connect", "~> 2.3"
gem "pg", "~> 1.4"
-gem "puma", "~> 6.1"
+gem "puma", "~> 6.4"
gem "rack", "~> 2.2"
gem "rack-utf8_sanitizer", "~> 1.8"
-gem "rbtrace", "~> 0.4.8"
-gem "rdoc", "~> 6.5"
+gem "rbtrace", "~> 0.5.1"
+gem "rdoc", "~> 6.6"
gem "roadie-rails", "~> 3.0"
gem "ruby-magic", "~> 0.6"
-gem "shoryuken", "~> 5.0", require: false
+gem "shoryuken", "~> 6.1", require: false
gem "statsd-instrument", "~> 3.5"
gem "validates_formatting_of", "~> 0.9"
-gem "opensearch-dsl", "~> 0.2.0"
-gem "opensearch-ruby", "~> 1.0"
-gem "searchkick", "~> 5.2"
-gem "faraday_middleware-aws-sigv4", "~> 0.6"
+gem "opensearch-ruby", "~> 3.1"
+gem "searchkick", "~> 5.3"
+gem "faraday_middleware-aws-sigv4", "~> 1.0"
gem "xml-simple", "~> 1.1"
-gem "compact_index", "~> 0.14.0"
+gem "compact_index", "~> 0.15.0"
gem "sprockets-rails", "~> 3.4"
gem "rack-attack", "~> 6.6"
gem "rqrcode", "~> 2.1"
gem "rotp", "~> 6.2"
gem "unpwn", "~> 1.0"
-gem "webauthn", "~> 3.0"
+gem "webauthn", "~> 3.1"
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"
+gem "bcrypt", "~> 3.1"
+gem "maintenance_tasks", "~> 2.4"
+gem "strong_migrations", "~> 1.7"
+gem "phlex-rails", "~> 1.1"
# Admin dashboard
-gem "avo", "~> 2.42"
-gem "view_component", "~> 3.6"
+gem "avo", "~> 2.46"
+gem "view_component", "~> 3.9"
gem "pundit", "~> 2.3"
gem "chartkick", "~> 5.0"
gem "groupdate", "~> 6.2"
# Logging
gem "amazing_print", "~> 1.4"
-gem "rails_semantic_logger", "~> 4.13"
+gem "rails_semantic_logger", "~> 4.14"
+gem "pp", "0.5.0"
group :assets, :development do
- gem "tailwindcss-rails", "~> 2.0"
+ gem "tailwindcss-rails", "~> 2.2"
end
group :assets do
- gem "dartsass-sprockets", "~> 3.0"
+ gem "dartsass-sprockets", "~> 3.1"
gem "terser", "~> 1.1"
gem "autoprefixer-rails", "~> 10.4"
end
@@ -79,10 +86,10 @@ end
group :development, :test do
gem "pry-byebug", "~> 3.10"
gem "toxiproxy", "~> 2.0"
- gem "factory_bot_rails", "~> 6.2"
+ gem "factory_bot_rails", "~> 6.4"
gem "dotenv-rails", "~> 2.8"
- gem "brakeman", "~> 6.0", require: false
+ gem "brakeman", "~> 6.1", require: false
gem "rubocop", "~> 1.48", require: false
gem "rubocop-rails", "~> 2.18", require: false
gem "rubocop-performance", "~> 1.16", require: false
@@ -106,8 +113,9 @@ group :test do
gem "rack-test", "~> 2.1", require: "rack/test"
gem "rails-controller-testing", "~> 1.0"
gem "mocha", "~> 2.0", require: false
- gem "shoulda", "~> 4.0"
- gem "selenium-webdriver", "~> 4.8"
+ gem "shoulda-context", "~> 2.0"
+ gem "shoulda-matchers", "~> 6.0"
+ gem "selenium-webdriver", "~> 4.16"
gem "webmock", "~> 3.18"
gem "simplecov", "~> 0.22", require: false
gem "simplecov-cobertura", "~> 2.1", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 189aacbe924..56361ed37eb 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -69,7 +69,7 @@ GEM
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
- addressable (2.8.5)
+ addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
aes_key_wrap (1.1.0)
aggregate_assertions (0.2.0)
@@ -81,10 +81,10 @@ GEM
ffi (~> 1.14)
ffi-compiler (~> 1.0)
ast (2.4.2)
- attr_required (1.0.1)
- autoprefixer-rails (10.4.15.0)
+ attr_required (1.0.2)
+ autoprefixer-rails (10.4.16.0)
execjs (~> 2)
- avo (2.44.0)
+ avo (2.46.0)
actionview (>= 6.0)
active_link_to
activerecord (>= 6.0)
@@ -100,27 +100,29 @@ GEM
view_component (>= 2.54.0)
zeitwerk (>= 2.6.2)
awrence (1.2.1)
- aws-eventstream (1.2.0)
- aws-partitions (1.848.0)
- aws-sdk-core (3.186.0)
- aws-eventstream (~> 1, >= 1.0.2)
+ aws-eventstream (1.3.0)
+ aws-partitions (1.873.0)
+ aws-sdk-core (3.190.1)
+ aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
- aws-sigv4 (~> 1.5)
+ aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.71.0)
- aws-sdk-core (~> 3, >= 3.177.0)
+ aws-sdk-kms (1.75.0)
+ aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.136.0)
- aws-sdk-core (~> 3, >= 3.181.0)
+ aws-sdk-s3 (1.142.0)
+ aws-sdk-core (~> 3, >= 3.189.0)
aws-sdk-kms (~> 1)
- aws-sigv4 (~> 1.6)
- aws-sdk-sqs (1.67.0)
- aws-sdk-core (~> 3, >= 3.184.0)
+ aws-sigv4 (~> 1.8)
+ aws-sdk-sqs (1.69.0)
+ aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
- aws-sigv4 (1.6.1)
+ aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
- bcrypt (3.1.19)
+ base64 (0.2.0)
+ bcrypt (3.1.20)
benchmark-ips (2.12.0)
+ bigdecimal (3.1.5)
bindata (2.4.15)
bitarray (1.2.0)
bloomer (1.0.0)
@@ -128,7 +130,8 @@ GEM
msgpack
bootsnap (1.17.0)
msgpack (~> 1.2)
- brakeman (6.0.1)
+ brakeman (6.1.1)
+ racc
browser (5.3.1)
builder (3.2.4)
byebug (11.1.3)
@@ -142,8 +145,8 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.9.6)
- cgi (0.3.6)
- chartkick (5.0.4)
+ cgi (0.4.0)
+ chartkick (5.0.5)
choice (0.2.0)
chunky_png (1.4.0)
clearance (2.6.1)
@@ -155,7 +158,7 @@ GEM
email_validator (~> 2.0)
railties (>= 5.0)
coderay (1.1.3)
- compact_index (0.14.0)
+ compact_index (0.15.0)
concurrent-ruby (1.2.2)
cose (1.3.0)
cbor (~> 0.5.9)
@@ -165,26 +168,25 @@ GEM
crass (1.0.6)
css_parser (1.16.0)
addressable
+ csv (3.2.8)
dalli (3.2.6)
- dartsass-ruby (3.0.1)
- sass-embedded (~> 1.54)
- dartsass-sprockets (3.0.0)
- dartsass-ruby (~> 3.0)
+ dartsass-sprockets (3.1.0)
railties (>= 4.0.0)
+ sassc-embedded (~> 1.69)
sprockets (> 3.0)
sprockets-rails
tilt
- datadog-ci (0.3.0)
+ datadog-ci (0.5.0)
msgpack
- date (3.3.3)
- ddtrace (1.16.1)
- datadog-ci (~> 0.3.0)
- debase-ruby_core_source (= 3.2.2)
+ date (3.3.4)
+ ddtrace (1.18.0)
+ datadog-ci (~> 0.5.0)
+ debase-ruby_core_source (= 3.2.3)
libdatadog (~> 5.0.0.1.0)
libddwaf (~> 1.14.0.0.0)
msgpack
dead_end (4.0.0)
- debase-ruby_core_source (3.2.2)
+ debase-ruby_core_source (3.2.3)
derailed_benchmarks (2.1.2)
benchmark-ips (~> 2)
dead_end
@@ -199,8 +201,7 @@ GEM
thor (>= 0.19, < 2)
docile (1.4.0)
dogstatsd-ruby (5.6.1)
- domain_name (0.5.20190701)
- unf (>= 0.0.5, < 1.0.0)
+ domain_name (0.6.20231109)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
@@ -214,37 +215,21 @@ GEM
et-orbi (1.2.7)
tzinfo
execjs (2.9.1)
- factory_bot (6.2.0)
+ factory_bot (6.4.5)
activesupport (>= 5.0.0)
- factory_bot_rails (6.2.0)
- factory_bot (~> 6.2.0)
+ factory_bot_rails (6.4.3)
+ factory_bot (~> 6.4)
railties (>= 5.0.0)
- faraday (1.10.3)
- faraday-em_http (~> 1.0)
- faraday-em_synchrony (~> 1.0)
- faraday-excon (~> 1.1)
- faraday-httpclient (~> 1.0)
- faraday-multipart (~> 1.0)
- faraday-net_http (~> 1.0)
- faraday-net_http_persistent (~> 1.0)
- faraday-patron (~> 1.0)
- faraday-rack (~> 1.0)
- faraday-retry (~> 1.0)
+ faraday (2.8.1)
+ base64
+ faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
- faraday-em_http (1.0.0)
- faraday-em_synchrony (1.0.0)
- faraday-excon (1.1.0)
- faraday-httpclient (1.0.1)
- faraday-multipart (1.0.4)
- multipart-post (~> 2)
- faraday-net_http (1.0.1)
- faraday-net_http_persistent (1.2.0)
- faraday-patron (1.0.0)
- faraday-rack (1.0.0)
- faraday-retry (1.0.3)
- faraday_middleware-aws-sigv4 (0.6.1)
+ faraday-follow_redirects (0.3.0)
+ faraday (>= 1, < 3)
+ faraday-net_http (3.0.2)
+ faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
- faraday (>= 1.8, < 2)
+ faraday (>= 2.0, < 3)
ffi (1.16.3)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
@@ -256,14 +241,14 @@ GEM
ffi (~> 1.0)
globalid (1.2.1)
activesupport (>= 6.1)
- good_job (3.21.0)
+ good_job (3.22.0)
activejob (>= 6.0.0)
activerecord (>= 6.0.0)
concurrent-ruby (>= 1.0.2)
fugit (>= 1.1)
railties (>= 6.0.0)
thor (>= 0.14.1)
- google-protobuf (3.25.0)
+ google-protobuf (3.25.1)
gravtastic (3.2.6)
groupdate (6.4.0)
activesupport (>= 6.1)
@@ -272,7 +257,7 @@ GEM
heapy (0.2.0)
thor
high_voltage (3.1.2)
- honeybadger (5.3.0)
+ honeybadger (5.4.1)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
@@ -285,7 +270,6 @@ GEM
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
- httpclient (2.8.3)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
inline_svg (1.9.0)
@@ -298,13 +282,15 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
- json (2.6.3)
- json-jwt (1.15.3)
+ json (2.7.1)
+ json-jwt (1.16.5)
activesupport (>= 4.2)
aes_key_wrap
+ base64
bindata
- httpclient
- jwt (2.7.0)
+ faraday (~> 2.0)
+ faraday-follow_redirects
+ jwt (2.7.1)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -317,7 +303,7 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
- launchdarkly-server-sdk (8.0.0)
+ launchdarkly-server-sdk (8.1.0)
concurrent-ruby (~> 1.1)
http (>= 4.4.0, < 6.0.0)
json (~> 2.3)
@@ -344,7 +330,7 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
- loofah (2.21.4)
+ loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -352,12 +338,13 @@ GEM
net-imap
net-pop
net-smtp
- maintenance_tasks (2.3.3)
+ maintenance_tasks (2.4.0)
actionpack (>= 6.0)
activejob (>= 6.0)
activerecord (>= 6.0)
job-iteration (>= 1.3.6)
railties (>= 6.0)
+ zeitwerk (>= 2.6.2)
marcel (1.0.2)
matrix (0.4.2)
memory_profiler (1.0.1)
@@ -381,18 +368,18 @@ GEM
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
- multipart-post (2.3.0)
- net-imap (0.3.7)
+ mutex_m (0.2.0)
+ net-imap (0.4.9)
date
net-protocol
net-pop (0.1.2)
net-protocol
- net-protocol (0.2.1)
+ net-protocol (0.2.2)
timeout
- net-smtp (0.3.3)
+ net-smtp (0.4.0.1)
net-protocol
- nio4r (2.5.9)
- nokogiri (1.15.4)
+ nio4r (2.7.0)
+ nokogiri (1.16.0)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oauth2 (2.0.9)
@@ -402,10 +389,11 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
+ observer (0.1.2)
octokit (8.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
- omniauth (2.1.1)
+ omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
@@ -418,43 +406,43 @@ GEM
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
- openid_connect (1.4.2)
+ openid_connect (2.3.0)
activemodel
attr_required (>= 1.0.0)
- json-jwt (>= 1.15.0)
- net-smtp
- rack-oauth2 (~> 1.21)
- swd (~> 1.3)
+ email_validator
+ faraday (~> 2.0)
+ faraday-follow_redirects
+ json-jwt (>= 1.16)
+ mail
+ rack-oauth2 (~> 2.2)
+ swd (~> 2.0)
tzinfo
- validate_email
validate_url
- webfinger (~> 1.2)
- opensearch-api (1.0.0)
- multi_json
- opensearch-dsl (0.2.1)
- opensearch-ruby (1.0.1)
- opensearch-api (= 1.0.0)
- opensearch-transport (~> 1.0.0)
- opensearch-transport (1.0.1)
+ webfinger (~> 2.0)
+ opensearch-ruby (3.1.0)
faraday (>= 1.0, < 3)
- multi_json
- openssl (3.1.0)
+ multi_json (>= 1.0)
+ openssl (3.2.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
- optimist (3.0.1)
- pagy (6.1.0)
+ optimist (3.1.0)
+ pagy (6.2.0)
parallel (1.22.1)
- parser (3.2.1.1)
+ parser (3.2.2.4)
ast (~> 2.4.1)
+ racc
pg (1.5.4)
- phlex (1.8.1)
+ phlex (1.9.0)
concurrent-ruby (~> 1.2)
erb (>= 4)
zeitwerk (~> 2.6)
- phlex-rails (1.0.0)
- phlex (~> 1.7)
- rails (>= 6.1, < 8)
+ phlex-rails (1.1.1)
+ phlex (~> 1.9)
+ railties (>= 6.1, < 8)
zeitwerk (~> 2.6)
+ pp (0.5.0)
+ prettyprint
+ prettyprint (0.2.0)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -463,8 +451,8 @@ GEM
pry (>= 0.13, < 0.15)
psych (5.1.1.1)
stringio
- public_suffix (5.0.3)
- puma (6.4.0)
+ public_suffix (5.0.4)
+ puma (6.4.2)
nio4r (~> 2.0)
pundit (2.3.1)
activesupport (>= 3.0.0)
@@ -474,14 +462,15 @@ GEM
rack (2.2.8)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
- rack-oauth2 (1.21.3)
+ rack-oauth2 (2.2.1)
activesupport
attr_required
- httpclient
+ faraday (~> 2.0)
+ faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
- rack-protection (3.0.5)
- rack
+ rack-protection (3.1.0)
+ rack (~> 2.2, >= 2.2.4)
rack-test (2.1.0)
rack (>= 1.3)
rack-utf8_sanitizer (1.9.1)
@@ -519,7 +508,7 @@ GEM
rails-i18n (7.0.8)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
- rails_semantic_logger (4.13.0)
+ rails_semantic_logger (4.14.0)
rack
railties (>= 5.1)
semantic_logger (~> 4.13)
@@ -535,13 +524,13 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
- rbtrace (0.4.14)
+ rbtrace (0.5.1)
ffi (>= 1.0.6)
msgpack (>= 0.4.3)
optimist (>= 3.0.0)
- rdoc (6.6.0)
+ rdoc (6.6.2)
psych (>= 4.0.0)
- regexp_parser (2.8.1)
+ regexp_parser (2.8.3)
rexml (3.2.6)
roadie (5.2.0)
css_parser (~> 1.4)
@@ -587,32 +576,31 @@ GEM
rubyzip (2.3.2)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
- sass-embedded (1.66.1)
- google-protobuf (~> 3.23)
+ sass-embedded (1.69.6)
+ google-protobuf (~> 3.25)
rake (>= 13.0.0)
+ sassc-embedded (1.69.1)
+ sass-embedded (~> 1.69)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
- searchkick (5.3.0)
+ searchkick (5.3.1)
activemodel (>= 6.1)
hashie
- selenium-webdriver (4.15.0)
+ selenium-webdriver (4.16.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic (1.6.1)
semantic_logger (4.15.0)
concurrent-ruby (~> 1.0)
- shoryuken (5.3.2)
+ shoryuken (6.1.1)
aws-sdk-core (>= 2)
concurrent-ruby
thor
- shoulda (4.0.0)
- shoulda-context (~> 2.0)
- shoulda-matchers (~> 4.0)
shoulda-context (2.0.0)
- shoulda-matchers (4.4.1)
- activesupport (>= 4.2.0)
+ shoulda-matchers (6.0.0)
+ activesupport (>= 5.2.0)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@@ -625,28 +613,29 @@ GEM
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
- sprockets (4.0.2)
+ sprockets (4.2.1)
concurrent-ruby (~> 1.0)
- rack (> 1, < 3)
+ rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
statsd-instrument (3.6.1)
- stringio (3.0.8)
- strong_migrations (1.6.4)
+ stringio (3.1.0)
+ strong_migrations (1.7.0)
activerecord (>= 5.2)
- swd (1.3.0)
+ swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
- httpclient (>= 2.4)
- tailwindcss-rails (2.0.32)
+ faraday (~> 2.0)
+ faraday-follow_redirects
+ tailwindcss-rails (2.2.1)
railties (>= 6.0.0)
- terser (1.1.19)
+ terser (1.1.20)
execjs (>= 0.3.0, < 3)
thor (1.3.0)
- tilt (2.2.0)
- timeout (0.4.0)
+ tilt (2.3.0)
+ timeout (0.4.1)
toxiproxy (2.0.2)
tpm-key_attestation (0.12.0)
bindata (~> 2.4)
@@ -660,27 +649,21 @@ GEM
turbo-rails (~> 1.3)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
- unf (0.1.4)
- unf_ext
- unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
unpwn (1.0.0)
bloomer (~> 1.0)
pwned (~> 2.0)
- validate_email (0.1.6)
- activemodel (>= 3.0)
- mail (>= 2.2.5)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
validates_formatting_of (0.9.0)
activemodel
version_gem (1.1.1)
- view_component (3.7.0)
+ view_component (3.9.0)
activesupport (>= 5.2.0, < 8.0)
concurrent-ruby (~> 1.0)
method_source (~> 1.0)
- webauthn (3.0.0)
+ webauthn (3.1.0)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
@@ -689,9 +672,10 @@ GEM
openssl (>= 2.2)
safety_net_attestation (~> 0.4.0)
tpm-key_attestation (~> 0.12.0)
- webfinger (1.2.0)
+ webfinger (2.1.3)
activesupport
- httpclient (>= 2.4)
+ faraday (~> 2.0)
+ faraday-follow_redirects
webmock (3.19.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@@ -713,59 +697,64 @@ DEPENDENCIES
aggregate_assertions (~> 0.2.0)
amazing_print (~> 1.4)
autoprefixer-rails (~> 10.4)
- avo (~> 2.42)
- aws-sdk-s3 (~> 1.119)
- aws-sdk-sqs (~> 1.67)
- bcrypt (~> 3.1, >= 3.1.18)
+ avo (~> 2.46)
+ aws-sdk-s3 (~> 1.142)
+ aws-sdk-sqs (~> 1.69)
+ bcrypt (~> 3.1)
+ bigdecimal (~> 3.1)
bootsnap (~> 1.16)
- brakeman (~> 6.0)
+ brakeman (~> 6.1)
browser (~> 5.3, >= 5.3.1)
capybara (~> 3.38)
chartkick (~> 5.0)
clearance (~> 2.6)
- compact_index (~> 0.14.0)
+ compact_index (~> 0.15.0)
+ csv (~> 3.2)
dalli (~> 3.2)
- dartsass-sprockets (~> 3.0)
- ddtrace (~> 1.10)
+ dartsass-sprockets (~> 3.1)
+ ddtrace (~> 1.18)
derailed_benchmarks (~> 2.1)
dogstatsd-ruby (~> 5.5)
dotenv-rails (~> 2.8)
- factory_bot_rails (~> 6.2)
- faraday (~> 1.10)
- faraday_middleware-aws-sigv4 (~> 0.6)
- good_job (~> 3.17)
- google-protobuf (~> 3.22)
+ factory_bot_rails (~> 6.4)
+ faraday (~> 2.8)
+ faraday_middleware-aws-sigv4 (~> 1.0)
+ good_job (~> 3.22)
+ google-protobuf (~> 3.25)
gravtastic (~> 3.2)
groupdate (~> 6.2)
high_voltage (~> 3.1)
- honeybadger (~> 5.2)
+ honeybadger (~> 5.4)
http_accept_language (~> 2.1)
jquery-rails (~> 4.5)
kaminari (~> 1.2)
- launchdarkly-server-sdk (~> 8.0)
+ launchdarkly-server-sdk (~> 8.1)
launchy (~> 2.5)
letter_opener (~> 1.8)
letter_opener_web (~> 2.0)
listen (~> 3.8)
mail (~> 2.8)
- maintenance_tasks (~> 2.1)
+ maintenance_tasks (~> 2.4)
memory_profiler (~> 1.0)
minitest (~> 5.18)
minitest-gcstats (~> 1.3)
minitest-profile (~> 0.0.2)
minitest-reporters (~> 1.6)
mocha (~> 2.0)
+ mutex_m (~> 0.2.0)
+ net-smtp (~> 0.4.0)
+ observer (~> 0.1.2)
octokit (~> 8.0)
omniauth (~> 2.1)
omniauth-github (~> 2.0)
omniauth-rails_csrf_protection (~> 1.0)
- openid_connect (~> 1.4)
- opensearch-dsl (~> 0.2.0)
- opensearch-ruby (~> 1.0)
+ openid_connect (~> 2.3)
+ opensearch-ruby (~> 3.1)
pg (~> 1.4)
- phlex-rails (~> 1.0)
+ phlex-rails (~> 1.1)
+ pp (= 0.5.0)
pry-byebug (~> 3.10)
- puma (~> 6.1)
+ puma (~> 6.4)
pundit (~> 2.3)
rack (~> 2.2)
rack-attack (~> 6.6)
@@ -775,9 +764,9 @@ DEPENDENCIES
rails-controller-testing (~> 1.0)
rails-erd (~> 1.7)
rails-i18n (~> 7.0)
- rails_semantic_logger (~> 4.13)
- rbtrace (~> 0.4.8)
- rdoc (~> 6.5)
+ rails_semantic_logger (~> 4.14)
+ rbtrace (~> 0.5.1)
+ rdoc (~> 6.6)
roadie-rails (~> 3.0)
rotp (~> 6.2)
rqrcode (~> 2.1)
@@ -787,24 +776,25 @@ DEPENDENCIES
rubocop-performance (~> 1.16)
rubocop-rails (~> 2.18)
ruby-magic (~> 0.6)
- searchkick (~> 5.2)
- selenium-webdriver (~> 4.8)
- shoryuken (~> 5.0)
- shoulda (~> 4.0)
+ searchkick (~> 5.3)
+ selenium-webdriver (~> 4.16)
+ shoryuken (~> 6.1)
+ shoulda-context (~> 2.0)
+ shoulda-matchers (~> 6.0)
simplecov (~> 0.22)
simplecov-cobertura (~> 2.1)
sprockets-rails (~> 3.4)
statsd-instrument (~> 3.5)
- strong_migrations (~> 1.6)
- tailwindcss-rails (~> 2.0)
+ strong_migrations (~> 1.7)
+ tailwindcss-rails (~> 2.2)
terser (~> 1.1)
toxiproxy (~> 2.0)
unpwn (~> 1.0)
validates_formatting_of (~> 0.9)
- view_component (~> 3.6)
- webauthn (~> 3.0)
+ view_component (~> 3.9)
+ webauthn (~> 3.1)
webmock (~> 3.18)
xml-simple (~> 1.1)
BUNDLED WITH
- 2.4.21
+ 2.5.4
diff --git a/app/assets/javascripts/webauthn.js b/app/assets/javascripts/webauthn.js
index 94a19edc8ff..30aff380ac9 100644
--- a/app/assets/javascripts/webauthn.js
+++ b/app/assets/javascripts/webauthn.js
@@ -1,16 +1,16 @@
(function() {
- var handleEvent = function(event) {
+ const handleEvent = function(event) {
event.preventDefault();
return event.target;
};
- var setError = function(submit, error, message) {
+ const setError = function(submit, error, message) {
submit.attr("disabled", false);
error.attr("hidden", false);
error.text(message);
};
- var handleHtmlResponse = function(submit, responseError, response) {
+ const handleHtmlResponse = function(submit, responseError, response) {
if (response.redirected) {
window.location.href = response.url;
} else {
@@ -22,7 +22,7 @@
}
};
- var credentialsToBase64 = function(credentials) {
+ const credentialsToBase64 = function(credentials) {
return {
type: credentials.type,
id: credentials.id,
@@ -32,12 +32,13 @@
authenticatorData: bufferToBase64url(credentials.response.authenticatorData),
attestationObject: bufferToBase64url(credentials.response.attestationObject),
clientDataJSON: bufferToBase64url(credentials.response.clientDataJSON),
- signature: bufferToBase64url(credentials.response.signature)
+ signature: bufferToBase64url(credentials.response.signature),
+ userHandle: bufferToBase64url(credentials.response.userHandle),
},
};
};
- var credentialsToBuffer = function(credentials) {
+ const credentialsToBuffer = function(credentials) {
return credentials.map(function(credential) {
return {
id: base64urlToBuffer(credential.id),
@@ -46,15 +47,32 @@
});
};
+ const parseCreationOptionsFromJSON = function(json) {
+ return {
+ ...json,
+ challenge: base64urlToBuffer(json.challenge),
+ user: { ...json.user, id: base64urlToBuffer(json.user.id) },
+ excludeCredentials: credentialsToBuffer(json.excludeCredentials),
+ };
+ };
+
+ const parseRequestOptionsFromJSON = function(json) {
+ return {
+ ...json,
+ challenge: base64urlToBuffer(json.challenge),
+ allowCredentials: credentialsToBuffer(json.allowCredentials),
+ };
+ };
+
$(function() {
- var credentialForm = $(".js-new-webauthn-credential--form");
- var credentialError = $(".js-new-webauthn-credential--error");
- var credentialSubmit = $(".js-new-webauthn-credential--submit");
- var csrfToken = $("[name='csrf-token']").attr("content");
+ const credentialForm = $(".js-new-webauthn-credential--form");
+ const credentialError = $(".js-new-webauthn-credential--error");
+ const credentialSubmit = $(".js-new-webauthn-credential--submit");
+ const csrfToken = $("[name='csrf-token']").attr("content");
credentialForm.submit(function(event) {
- var form = handleEvent(event);
- var nickname = $(".js-new-webauthn-credential--nickname").val();
+ const form = handleEvent(event);
+ const nickname = $(".js-new-webauthn-credential--nickname").val();
fetch(form.action + ".json", {
method: "POST",
@@ -63,11 +81,8 @@
}).then(function (response) {
return response.json();
}).then(function (json) {
- json.user.id = base64urlToBuffer(json.user.id);
- json.challenge = base64urlToBuffer(json.challenge);
- json.excludeCredentials = credentialsToBuffer(json.excludeCredentials);
return navigator.credentials.create({
- publicKey: json
+ publicKey: parseCreationOptionsFromJSON(json)
});
}).then(function (credentials) {
return fetch(form.action + "/callback.json", {
@@ -98,36 +113,33 @@
});
});
- var getCredentials = function(event, csrfToken) {
- var form = handleEvent(event);
- var options = JSON.parse(form.dataset.options);
- options.challenge = base64urlToBuffer(options.challenge);
- options.allowCredentials = credentialsToBuffer(options.allowCredentials);
- return navigator.credentials.get({
- publicKey: options
- }).then(function (credentials) {
- return fetch(form.action, {
- method: "POST",
- credentials: "same-origin",
- redirect: "follow",
- headers: {
- "X-CSRF-Token": csrfToken,
- "Content-Type": "application/json"
- },
- body: JSON.stringify({
- credentials: credentialsToBase64(credentials)
- })
- });
- })
+ const getCredentials = async function(event, csrfToken) {
+ const form = handleEvent(event);
+ const options = JSON.parse(form.dataset.options);
+ const credentials = await navigator.credentials.get({
+ publicKey: parseRequestOptionsFromJSON(options)
+ });
+ return await fetch(form.action, {
+ method: "POST",
+ credentials: "same-origin",
+ redirect: "follow",
+ headers: {
+ "X-CSRF-Token": csrfToken,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ credentials: credentialsToBase64(credentials),
+ })
+ });
};
$(function() {
- var cliSessionForm = $(".js-webauthn-session-cli--form");
- var cliSessionError = $(".js-webauthn-session-cli--error");
- var csrfToken = $("[name='csrf-token']").attr("content");
+ const cliSessionForm = $(".js-webauthn-session-cli--form");
+ const cliSessionError = $(".js-webauthn-session-cli--error");
+ const csrfToken = $("[name='csrf-token']").attr("content");
function failed_verification_url(message) {
- var url = new URL(`${location.origin}/webauthn_verification/failed_verification`);
+ const url = new URL(`${location.origin}/webauthn_verification/failed_verification`);
url.searchParams.append("error", message);
return url.href;
};
@@ -148,17 +160,18 @@
});
$(function() {
- var sessionForm = $(".js-webauthn-session--form");
- var sessionSubmit = $(".js-webauthn-session--submit");
- var sessionError = $(".js-webauthn-session--error");
- var csrfToken = $("[name='csrf-token']").attr("content");
+ const sessionForm = $(".js-webauthn-session--form");
+ const sessionSubmit = $(".js-webauthn-session--submit");
+ const sessionError = $(".js-webauthn-session--error");
+ const csrfToken = $("[name='csrf-token']").attr("content");
- sessionForm.submit(function(event) {
- getCredentials(event, csrfToken).then(function (response) {
+ sessionForm.submit(async function(event) {
+ try {
+ const response = await getCredentials(event, csrfToken);
handleHtmlResponse(sessionSubmit, sessionError, response);
- }).catch(function (error) {
+ } catch (error) {
setError(sessionSubmit, sessionError, error);
- });
+ }
});
});
})();
diff --git a/app/avo/actions/upload_names_file.rb b/app/avo/actions/upload_names_file.rb
new file mode 100644
index 00000000000..1ab53b4ce95
--- /dev/null
+++ b/app/avo/actions/upload_names_file.rb
@@ -0,0 +1,18 @@
+class UploadNamesFile < BaseAction
+ self.name = "Upload Names File"
+ self.visible = lambda {
+ current_user.team_member?("rubygems-org") && view == :index
+ }
+ self.standalone = true
+ self.confirm_button_label = "Upload"
+
+ class ActionHandler < ActionHandler
+ def handle_standalone
+ UploadNamesFileJob.perform_later
+
+ succeed("Upload job scheduled")
+
+ Version.last
+ end
+ end
+end
diff --git a/app/avo/concerns/auditable.rb b/app/avo/concerns/auditable.rb
index f6654400e57..79cd0667a75 100644
--- a/app/avo/concerns/auditable.rb
+++ b/app/avo/concerns/auditable.rb
@@ -10,7 +10,7 @@ def merge_changes!(changes, changes_to_save)
end
end
- def in_audited_transaction(auditable:, admin_github_user:, action:, fields:, arguments:, models:, &) # rubocop:disable Metrics
+ def in_audited_transaction(auditable:, admin_github_user:, action:, fields:, arguments:, models:, &blk) # rubocop:disable Metrics Naming/BlockForwarding
logger.debug { "Auditing changes to #{auditable}: #{fields}" }
User.transaction do
@@ -25,7 +25,7 @@ def in_audited_transaction(auditable:, admin_github_user:, action:, fields:, arg
merge_changes!((changed_records[record] ||= {}), record.attributes.transform_values { [nil, _1] }) if record.new_record?
merge_changes!((changed_records[record] ||= {}), record.changes_to_save)
end
- end, "sql.active_record", &)
+ end, "sql.active_record", &blk)
case auditable
when :return
diff --git a/app/avo/resources/api_key_resource.rb b/app/avo/resources/api_key_resource.rb
index d54462c3413..70832a69514 100644
--- a/app/avo/resources/api_key_resource.rb
+++ b/app/avo/resources/api_key_resource.rb
@@ -9,7 +9,10 @@ class ExpiredFilter < ScopeBooleanFilter; end
field :name, as: :text, link_to_resource: true
field :hashed_key, as: :text, visible: ->(_) { false }
- field :user, as: :belongs_to
+ field :user, as: :belongs_to, visible: ->(_) { false }
+ field :owner, as: :belongs_to,
+ polymorphic_as: :owner,
+ types: [::User, ::OIDC::TrustedPublisher::GitHubAction]
field :last_accessed_at, as: :date_time
field :soft_deleted_at, as: :date_time
field :soft_deleted_rubygem_name, as: :text
diff --git a/app/avo/resources/oidc_pending_trusted_publisher_resource.rb b/app/avo/resources/oidc_pending_trusted_publisher_resource.rb
new file mode 100644
index 00000000000..f546d3a8df1
--- /dev/null
+++ b/app/avo/resources/oidc_pending_trusted_publisher_resource.rb
@@ -0,0 +1,16 @@
+class OIDCPendingTrustedPublisherResource < Avo::BaseResource
+ self.title = :id
+ self.includes = []
+ self.model_class = ::OIDC::PendingTrustedPublisher
+
+ class ExpiredFilter < ScopeBooleanFilter; end
+ filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } }
+
+ field :id, as: :id
+ # Fields generated from the model
+ field :rubygem_name, as: :text
+ field :user, as: :belongs_to
+ field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher
+ field :expires_at, as: :date_time
+ # add fields here
+end
diff --git a/app/avo/resources/oidc_provider_resource.rb b/app/avo/resources/oidc_provider_resource.rb
index 7f209c2c895..8a2fa0fe6d4 100644
--- a/app/avo/resources/oidc_provider_resource.rb
+++ b/app/avo/resources/oidc_provider_resource.rb
@@ -2,9 +2,6 @@ class OIDCProviderResource < Avo::BaseResource
self.title = :issuer
self.includes = []
self.model_class = ::OIDC::Provider
- # self.search_query = -> do
- # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false)
- # end
action RefreshOIDCProvider
diff --git a/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb b/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb
new file mode 100644
index 00000000000..96c63c43096
--- /dev/null
+++ b/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb
@@ -0,0 +1,11 @@
+class OIDCRubygemTrustedPublisherResource < Avo::BaseResource
+ self.title = :id
+ self.includes = [:trusted_publisher]
+ self.model_class = ::OIDC::RubygemTrustedPublisher
+
+ field :id, as: :id
+ # Fields generated from the model
+ field :rubygem, as: :belongs_to
+ field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher
+ # add fields here
+end
diff --git a/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb b/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb
new file mode 100644
index 00000000000..0ea00fd83e4
--- /dev/null
+++ b/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb
@@ -0,0 +1,19 @@
+class OIDCTrustedPublisherGitHubActionResource < Avo::BaseResource
+ self.title = :name
+ self.includes = []
+ self.model_class = ::OIDC::TrustedPublisher::GitHubAction
+
+ field :id, as: :id
+ # Fields generated from the model
+ field :repository_owner, as: :text
+ field :repository_name, as: :text
+ field :repository_owner_id, as: :text
+ field :workflow_filename, as: :text
+ field :environment, as: :text
+ # add fields here
+ #
+ field :rubygem_trusted_publishers, as: :has_many
+ field :pending_trusted_publishers, as: :has_many
+ field :rubygems, as: :has_many, through: :rubygem_trusted_publishers
+ field :api_keys, as: :has_many, inverse_of: :owner
+end
diff --git a/app/avo/resources/rubygem_resource.rb b/app/avo/resources/rubygem_resource.rb
index f11e4b65e64..e942e207847 100644
--- a/app/avo/resources/rubygem_resource.rb
+++ b/app/avo/resources/rubygem_resource.rb
@@ -9,6 +9,7 @@ class RubygemResource < Avo::BaseResource
action AddOwner
action YankRubygem
action UploadInfoFile
+ action UploadNamesFile
action UploadVersionsFile
class IndexedFilter < ScopeBooleanFilter; end
@@ -37,6 +38,9 @@ class IndexedFilter < ScopeBooleanFilter; end
field :linkset, as: :has_one
field :gem_download, as: :has_one
+ field :link_verifications, as: :has_many
+ field :oidc_rubygem_trusted_publishers, as: :has_many
+
field :audits, as: :has_many
end
end
diff --git a/app/avo/resources/user_resource.rb b/app/avo/resources/user_resource.rb
index d76eacea65a..71c93636dc0 100644
--- a/app/avo/resources/user_resource.rb
+++ b/app/avo/resources/user_resource.rb
@@ -42,8 +42,7 @@ class UserResource < Avo::BaseResource
field :mfa_level, as: :select, enum: ::User.mfa_levels
field :mfa_recovery_codes, as: :text, visible: ->(_) { false }
field :mfa_hashed_recovery_codes, as: :text, visible: ->(_) { false }
- field :webauthn_id, as: :text, visible: ->(_) { false }
- field :webauthn_credentials, as: :has_many, visible: ->(_) { false }
+ field :webauthn_id, as: :text
field :remember_token_expires_at, as: :date_time
field :api_key, as: :text, visible: ->(_) { false }
field :confirmation_token, as: :text, visible: ->(_) { false }
@@ -64,6 +63,8 @@ class UserResource < Avo::BaseResource
field :ownership_requests, as: :has_many
field :pushed_versions, as: :has_many
field :oidc_api_key_roles, as: :has_many
+ field :webauthn_credentials, as: :has_many
+ field :webauthn_verification, as: :has_one
field :audits, as: :has_many
end
diff --git a/app/avo/resources/webauthn_credential_resource.rb b/app/avo/resources/webauthn_credential_resource.rb
new file mode 100644
index 00000000000..b6711b63eab
--- /dev/null
+++ b/app/avo/resources/webauthn_credential_resource.rb
@@ -0,0 +1,13 @@
+class WebauthnCredentialResource < Avo::BaseResource
+ self.title = :id
+ self.includes = []
+
+ field :id, as: :id
+ # Fields generated from the model
+ field :external_id, as: :text
+ field :public_key, as: :text
+ field :nickname, as: :text
+ field :sign_count, as: :number
+ field :user, as: :belongs_to
+ # add fields here
+end
diff --git a/app/avo/resources/webauthn_verification_resource.rb b/app/avo/resources/webauthn_verification_resource.rb
new file mode 100644
index 00000000000..5834540ac89
--- /dev/null
+++ b/app/avo/resources/webauthn_verification_resource.rb
@@ -0,0 +1,13 @@
+class WebauthnVerificationResource < Avo::BaseResource
+ self.title = :id
+ self.includes = []
+
+ field :id, as: :id
+ # Fields generated from the model
+ field :path_token, as: :text
+ field :path_token_expires_at, as: :date_time
+ field :otp, as: :text
+ field :otp_expires_at, as: :date_time
+ field :user, as: :belongs_to
+ # add fields here
+end
diff --git a/app/controllers/adoptions_controller.rb b/app/controllers/adoptions_controller.rb
index f1dce7e204d..5c3cdb58a5e 100644
--- a/app/controllers/adoptions_controller.rb
+++ b/app/controllers/adoptions_controller.rb
@@ -1,7 +1,9 @@
class AdoptionsController < ApplicationController
+ include SessionVerifiable
+
before_action :find_rubygem
before_action :verify_ownership_requestable
- before_action :redirect_to_verify, if: :current_user_is_owner?
+ before_action :redirect_to_verify, if: -> { current_user_is_owner? && !verified_session_active? }
def index
@ownership_call = @rubygem.ownership_call
@@ -18,10 +20,4 @@ def verify_ownership_requestable
def current_user_is_owner?
@rubygem.owned_by?(current_user)
end
-
- def redirect_to_verify
- return if password_session_active?
- session[:redirect_uri] = rubygem_adoptions_path(@rubygem.slug)
- redirect_to verify_session_path
- end
end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 69013864306..c385e5de60d 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -1,5 +1,6 @@
class Api::BaseController < ApplicationController
skip_before_action :verify_authenticity_token
+ after_action :skip_session
private
@@ -44,23 +45,23 @@ def verify_with_otp
def verify_mfa_requirement
if @rubygem && !@rubygem.mfa_requirement_satisfied_for?(@api_key.user)
render plain: "Gem requires MFA enabled; You do not have MFA enabled yet.", status: :forbidden
- elsif @api_key.user.mfa_required_not_yet_enabled?
+ elsif @api_key.mfa_required_not_yet_enabled?
render_mfa_setup_required_error
- elsif @api_key.user.mfa_required_weak_level_enabled?
+ elsif @api_key.mfa_required_weak_level_enabled?
render_mfa_strong_level_required_error
end
end
def response_with_mfa_warning(response)
message = response
- if @api_key.user.mfa_recommended_not_yet_enabled?
+ if @api_key.mfa_recommended_not_yet_enabled?
message += <<~WARN.chomp
[WARNING] For protection of your account and gems, we encourage you to set up multi-factor authentication \
at https://rubygems.org/multifactor_auth/new. Your account will be required to have MFA enabled in the future.
WARN
- elsif @api_key.user.mfa_recommended_weak_level_enabled?
+ elsif @api_key.mfa_recommended_weak_level_enabled?
message += <<~WARN.chomp
@@ -98,10 +99,14 @@ def authenticate_with_api_key
hashed_key = Digest::SHA256.hexdigest(params_key)
@api_key = ApiKey.unexpired.find_by_hashed_key(hashed_key)
return render_unauthorized unless @api_key
- set_tags "gemcutter.user.id" => @api_key.user_id, "gemcutter.user.api_key_id" => @api_key.id
+ set_tags "gemcutter.api_key.owner" => @api_key.owner.to_gid, "gemcutter.user.api_key_id" => @api_key.id
render_soft_deleted_api_key if @api_key.soft_deleted?
end
+ def verify_user_api_key
+ render_api_key_forbidden if @api_key.user.blank?
+ end
+
def render_unauthorized
render plain: t(:please_sign_up), status: :unauthorized
end
@@ -117,4 +122,8 @@ def render_api_key_forbidden
def render_soft_deleted_api_key
render plain: "An invalid API key cannot be used. Please delete it and create a new one.", status: :forbidden
end
+
+ def skip_session
+ request.session_options[:skip] = true
+ end
end
diff --git a/app/controllers/api/compact_index_controller.rb b/app/controllers/api/compact_index_controller.rb
index 92d153a9a75..386e3cad3c1 100644
--- a/app/controllers/api/compact_index_controller.rb
+++ b/app/controllers/api/compact_index_controller.rb
@@ -28,8 +28,10 @@ def info
private
def render_range(response_body)
- headers["ETag"] = '"' << Digest::MD5.hexdigest(response_body) << '"'
- headers["Digest"] = "sha-256=#{Digest::SHA256.base64digest(response_body)}"
+ headers["ETag"] = %("#{Digest::MD5.hexdigest(response_body)}")
+ digest = Digest::SHA256.base64digest(response_body)
+ headers["Digest"] = "sha-256=#{digest}"
+ headers["Repr-Digest"] = "sha-256=:#{digest}:"
headers["Accept-Ranges"] = "bytes"
ranges = Rack::Utils.byte_ranges(request.env, response_body.bytesize)
diff --git a/app/controllers/api/v1/api_keys_controller.rb b/app/controllers/api/v1/api_keys_controller.rb
index 71e66afa45e..bf026ec097d 100644
--- a/app/controllers/api/v1/api_keys_controller.rb
+++ b/app/controllers/api/v1/api_keys_controller.rb
@@ -20,7 +20,7 @@ def create
check_mfa(user) do
key = generate_unique_rubygems_key
- build_params = { user: user, hashed_key: hashed_key(key), **api_key_create_params }
+ build_params = { owner: user, hashed_key: hashed_key(key), **api_key_create_params }
api_key = ApiKey.new(build_params)
save_and_respond(api_key, key)
diff --git a/app/controllers/api/v1/deletions_controller.rb b/app/controllers/api/v1/deletions_controller.rb
index 68b048ff659..ecdef8322cc 100644
--- a/app/controllers/api/v1/deletions_controller.rb
+++ b/app/controllers/api/v1/deletions_controller.rb
@@ -1,5 +1,6 @@
class Api::V1::DeletionsController < Api::BaseController
before_action :authenticate_with_api_key
+ before_action :verify_user_api_key
before_action :find_rubygem_by_name
before_action :verify_api_key_gem_scope
before_action :validate_gem_and_version
diff --git a/app/controllers/api/v1/github_secret_scanning_controller.rb b/app/controllers/api/v1/github_secret_scanning_controller.rb
index a74fab71d6e..c49e6676973 100644
--- a/app/controllers/api/v1/github_secret_scanning_controller.rb
+++ b/app/controllers/api/v1/github_secret_scanning_controller.rb
@@ -28,7 +28,7 @@ def revoke
resp = []
tokens.each do |t|
api_key = ApiKey.find_by(hashed_key: hashed_key(t[:token]))
- label = if api_key&.destroy
+ label = if api_key&.expire!
schedule_revoke_email(api_key, t[:url])
"true_positive"
else
@@ -50,7 +50,8 @@ def revoke
private
def schedule_revoke_email(api_key, url)
- Mailer.api_key_revoked(api_key.user_id, api_key.name, api_key.enabled_scopes.join(", "), url).deliver_later
+ return unless api_key.user?
+ Mailer.api_key_revoked(api_key.owner_id, api_key.name, api_key.enabled_scopes.join(", "), url).deliver_later
end
def secret_scanning_key(key_id)
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 77784d0e07a..fde11eb2c3a 100644
--- a/app/controllers/api/v1/oidc/api_key_roles_controller.rb
+++ b/app/controllers/api/v1/oidc/api_key_roles_controller.rb
@@ -2,6 +2,7 @@ class Api::V1::OIDC::ApiKeyRolesController < Api::BaseController
include ApiKeyable
before_action :authenticate_with_api_key, except: :assume_role
+ before_action :verify_user_api_key, except: :assume_role
with_options only: :assume_role do
before_action :set_api_key_role
diff --git a/app/controllers/api/v1/oidc/id_tokens_controller.rb b/app/controllers/api/v1/oidc/id_tokens_controller.rb
index 70e6aa6be9b..36b3f5ef11c 100644
--- a/app/controllers/api/v1/oidc/id_tokens_controller.rb
+++ b/app/controllers/api/v1/oidc/id_tokens_controller.rb
@@ -1,5 +1,6 @@
class Api::V1::OIDC::IdTokensController < Api::BaseController
before_action :authenticate_with_api_key
+ before_action :verify_user_api_key
def index
render json: @api_key.user.oidc_id_tokens
diff --git a/app/controllers/api/v1/oidc/providers_controller.rb b/app/controllers/api/v1/oidc/providers_controller.rb
index bb285adfd02..dd848058526 100644
--- a/app/controllers/api/v1/oidc/providers_controller.rb
+++ b/app/controllers/api/v1/oidc/providers_controller.rb
@@ -1,5 +1,6 @@
class Api::V1::OIDC::ProvidersController < Api::BaseController
before_action :authenticate_with_api_key
+ before_action :verify_user_api_key
def index
render json: OIDC::Provider.all
diff --git a/app/controllers/api/v1/oidc/trusted_publisher_controller.rb b/app/controllers/api/v1/oidc/trusted_publisher_controller.rb
new file mode 100644
index 00000000000..d5046822924
--- /dev/null
+++ b/app/controllers/api/v1/oidc/trusted_publisher_controller.rb
@@ -0,0 +1,71 @@
+class Api::V1::OIDC::TrustedPublisherController < Api::BaseController
+ include ApiKeyable
+
+ before_action :decode_jwt
+ before_action :find_provider
+ before_action :verify_signature
+ before_action :find_trusted_publisher
+ before_action :validate_claims
+
+ class UnsupportedIssuer < StandardError; end
+ class UnverifiedJWT < StandardError; end
+
+ rescue_from(
+ UnsupportedIssuer, UnverifiedJWT,
+ JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound,
+ OIDC::AccessPolicy::AccessError,
+ with: :render_not_found
+ )
+
+ def exchange_token
+ key = generate_unique_rubygems_key
+ iat = Time.at(@jwt[:iat].to_i, in: "UTC")
+ api_key = @trusted_publisher.api_keys.create!(
+ hashed_key: hashed_key(key),
+ name: "#{@trusted_publisher.name} #{iat.iso8601}",
+ push_rubygem: true,
+ expires_at: 15.minutes.from_now
+ )
+
+ render json: {
+ rubygems_api_key: key,
+ name: api_key.name,
+ scopes: api_key.enabled_scopes,
+ gem: api_key.rubygem,
+ expires_at: api_key.expires_at
+ }.compact, status: :created
+ end
+
+ private
+
+ def decode_jwt
+ @jwt = JSON::JWT.decode_compact_serialized(params.require(:jwt), :skip_verification)
+ rescue JSON::JWT::InvalidFormat, JSON::ParserError, ArgumentError
+ # invalid base64 raises ArgumentError
+ render_bad_request
+ end
+
+ def find_provider
+ @provider = OIDC::Provider.find_by!(issuer: @jwt[:iss])
+ end
+
+ def verify_signature
+ raise UnverifiedJWT, "Invalid time" unless (@jwt["nbf"]..@jwt["exp"]).cover?(Time.now.to_i)
+ @jwt.verify!(@provider.jwks)
+ end
+
+ def find_trusted_publisher
+ unless (trusted_publisher_class = @provider.trusted_publisher_class)
+ raise UnsupportedIssuer, "Unsuported issuer for trusted publishing"
+ end
+ @trusted_publisher = trusted_publisher_class.for_claims(@jwt)
+ end
+
+ def validate_claims
+ @trusted_publisher.to_access_policy(@jwt).verify_access!(@jwt)
+ end
+
+ def render_bad_request
+ render json: { error: "Bad Request" }, status: :bad_request
+ end
+end
diff --git a/app/controllers/api/v1/owners_controller.rb b/app/controllers/api/v1/owners_controller.rb
index 96ba0b81e08..6c71e10de93 100644
--- a/app/controllers/api/v1/owners_controller.rb
+++ b/app/controllers/api/v1/owners_controller.rb
@@ -1,5 +1,6 @@
class Api::V1::OwnersController < Api::BaseController
before_action :authenticate_with_api_key, except: %i[show gems]
+ before_action :verify_user_api_key, except: %i[show gems]
before_action :find_rubygem, except: :gems
before_action :verify_api_key_gem_scope, except: %i[show gems]
before_action :verify_gem_ownership, except: %i[show gems]
diff --git a/app/controllers/api/v1/rubygems_controller.rb b/app/controllers/api/v1/rubygems_controller.rb
index 42f85e7319c..f0400afd0a0 100644
--- a/app/controllers/api/v1/rubygems_controller.rb
+++ b/app/controllers/api/v1/rubygems_controller.rb
@@ -1,6 +1,7 @@
class Api::V1::RubygemsController < Api::BaseController
before_action :authenticate_with_api_key, except: %i[show reverse_dependencies]
- before_action :find_rubygem, only: %i[show reverse_dependencies]
+ before_action :verify_user_api_key, except: %i[show reverse_dependencies create]
+ before_action :find_rubygem, only: %i[show reverse_dependencies]
before_action :cors_preflight_check, only: :show
before_action :verify_with_otp, only: %i[create]
before_action :verify_mfa_requirement, only: %i[create]
diff --git a/app/controllers/api/v1/web_hooks_controller.rb b/app/controllers/api/v1/web_hooks_controller.rb
index 783630646c3..3127ac4411f 100644
--- a/app/controllers/api/v1/web_hooks_controller.rb
+++ b/app/controllers/api/v1/web_hooks_controller.rb
@@ -1,5 +1,6 @@
class Api::V1::WebHooksController < Api::BaseController
before_action :authenticate_with_api_key
+ before_action :verify_user_api_key
before_action :render_api_key_forbidden, if: :api_key_unauthorized?
before_action :find_rubygem_by_name, :set_url, except: :index
diff --git a/app/controllers/api/v1/webauthn_verifications_controller.rb b/app/controllers/api/v1/webauthn_verifications_controller.rb
index 6fbdd2663dc..d2ed0e48bb5 100644
--- a/app/controllers/api/v1/webauthn_verifications_controller.rb
+++ b/app/controllers/api/v1/webauthn_verifications_controller.rb
@@ -35,13 +35,13 @@ def status
def authenticate_with_credentials
params_key = request.headers["Authorization"] || ""
hashed_key = Digest::SHA256.hexdigest(params_key)
- api_key = ApiKey.find_by_hashed_key(hashed_key)
+ api_key = ApiKey.unexpired.find_by_hashed_key(hashed_key)
@user = authenticated_user(api_key)
end
def authenticated_user(api_key)
- return api_key.user if api_key
+ return api_key.user if api_key&.user?
authenticate_or_request_with_http_basic do |username, password|
User.authenticate(username.strip, password)
end
diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb
index 286b0b10afb..ec7ce441ca6 100644
--- a/app/controllers/api_keys_controller.rb
+++ b/app/controllers/api_keys_controller.rb
@@ -1,9 +1,8 @@
class ApiKeysController < 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?
+
+ include SessionVerifiable
+ verify_session_before
def index
@api_key = session.delete(:api_key)
@@ -25,7 +24,7 @@ def edit
def create
key = generate_unique_rubygems_key
- build_params = { user: current_user, hashed_key: hashed_key(key), **api_key_params }
+ build_params = { owner: current_user, hashed_key: hashed_key(key), **api_key_params }
@api_key = ApiKey.new(build_params)
if @api_key.errors.present?
@@ -84,12 +83,20 @@ def reset
private
- def api_key_params
- params.require(:api_key).permit(:name, *ApiKey::API_SCOPES, :mfa, :rubygem_id)
+ def verify_session_redirect_path
+ case action_name
+ when "reset", "destroy"
+ profile_api_keys_path
+ when "create"
+ new_profile_api_key_path
+ when "update"
+ edit_profile_api_key_path(params.require(:id))
+ else
+ super
+ end
end
- def redirect_to_verify
- session[:redirect_uri] = profile_api_keys_path
- redirect_to verify_session_path
+ def api_key_params
+ params.require(:api_key).permit(:name, *ApiKey::API_SCOPES, :mfa, :rubygem_id)
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 32d8420d12f..92aed8dd6c6 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,6 +1,5 @@
class ApplicationController < ActionController::Base
include Clearance::Authentication
- include Clearance::Authorization
include ApplicationMultifactorMethods
include TraceTagger
@@ -69,6 +68,10 @@ def redirect_to_signin
redirect_to sign_in_path, alert: t("please_sign_in")
end
+ def redirect_to_root
+ redirect_to root_path
+ end
+
def find_rubygem
@rubygem = Rubygem.find_by_name(params[:rubygem_id] || params[:id])
return if @rubygem
@@ -150,10 +153,6 @@ def set_cache_headers
response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
end
- def password_session_active?
- session[:verification] && session[:verification] > Time.current && session.fetch(:verified_user, "") == current_user.id
- end
-
def set_error_context_user
return unless current_user
diff --git a/app/controllers/avo/oidc_pending_trusted_publishers_controller.rb b/app/controllers/avo/oidc_pending_trusted_publishers_controller.rb
new file mode 100644
index 00000000000..44059a5649f
--- /dev/null
+++ b/app/controllers/avo/oidc_pending_trusted_publishers_controller.rb
@@ -0,0 +1,4 @@
+# This controller has been generated to enable Rails' resource routes.
+# More information on https://docs.avohq.io/2.0/controllers.html
+class Avo::OIDCPendingTrustedPublishersController < Avo::ResourcesController
+end
diff --git a/app/controllers/avo/oidc_rubygem_trusted_publishers_controller.rb b/app/controllers/avo/oidc_rubygem_trusted_publishers_controller.rb
new file mode 100644
index 00000000000..a9559a522d4
--- /dev/null
+++ b/app/controllers/avo/oidc_rubygem_trusted_publishers_controller.rb
@@ -0,0 +1,4 @@
+# This controller has been generated to enable Rails' resource routes.
+# More information on https://docs.avohq.io/2.0/controllers.html
+class Avo::OIDCRubygemTrustedPublishersController < Avo::ResourcesController
+end
diff --git a/app/controllers/avo/oidc_trusted_publisher_github_actions_controller.rb b/app/controllers/avo/oidc_trusted_publisher_github_actions_controller.rb
new file mode 100644
index 00000000000..f35ba803f21
--- /dev/null
+++ b/app/controllers/avo/oidc_trusted_publisher_github_actions_controller.rb
@@ -0,0 +1,4 @@
+# This controller has been generated to enable Rails' resource routes.
+# More information on https://docs.avohq.io/2.0/controllers.html
+class Avo::OIDCTrustedPublisherGitHubActionsController < Avo::ResourcesController
+end
diff --git a/app/controllers/avo/webauthn_credentials_controller.rb b/app/controllers/avo/webauthn_credentials_controller.rb
new file mode 100644
index 00000000000..f16d7df74bc
--- /dev/null
+++ b/app/controllers/avo/webauthn_credentials_controller.rb
@@ -0,0 +1,4 @@
+# This controller has been generated to enable Rails' resource routes.
+# More information on https://docs.avohq.io/2.0/controllers.html
+class Avo::WebauthnCredentialsController < Avo::ResourcesController
+end
diff --git a/app/controllers/avo/webauthn_verifications_controller.rb b/app/controllers/avo/webauthn_verifications_controller.rb
new file mode 100644
index 00000000000..16817b148b2
--- /dev/null
+++ b/app/controllers/avo/webauthn_verifications_controller.rb
@@ -0,0 +1,4 @@
+# This controller has been generated to enable Rails' resource routes.
+# More information on https://docs.avohq.io/2.0/controllers.html
+class Avo::WebauthnVerificationsController < Avo::ResourcesController
+end
diff --git a/app/controllers/concerns/avo_auditable.rb b/app/controllers/concerns/avo_auditable.rb
index 3a4ce7313eb..457847b7c47 100644
--- a/app/controllers/concerns/avo_auditable.rb
+++ b/app/controllers/concerns/avo_auditable.rb
@@ -5,7 +5,7 @@ module AvoAuditable
include Auditable
end
- def perform_action_and_record_errors(&)
+ def perform_action_and_record_errors(&blk) # rubocop:disable Naming/BlockForwarding
super do
action = params.fetch(:action)
fields = action == "destroy" ? {} : cast_nullable(model_params)
@@ -21,7 +21,7 @@ def perform_action_and_record_errors(&)
fields: fields.reverse_merge(comment: action_name),
arguments: {},
models: [@model],
- &
+ &blk # rubocop:disable Naming/BlockForwarding
)
value
end
diff --git a/app/controllers/concerns/session_verifiable.rb b/app/controllers/concerns/session_verifiable.rb
new file mode 100644
index 00000000000..864e9c95747
--- /dev/null
+++ b/app/controllers/concerns/session_verifiable.rb
@@ -0,0 +1,40 @@
+module SessionVerifiable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def verify_session_before(**opts)
+ before_action :redirect_to_signin, **opts, unless: :signed_in?
+ before_action :redirect_to_new_mfa, **opts, if: :mfa_required_not_yet_enabled?
+ before_action :redirect_to_settings_strong_mfa_required, **opts, if: :mfa_required_weak_level_enabled?
+ before_action :redirect_to_verify, **opts, unless: :verified_session_active?
+ end
+ end
+
+ private
+
+ def verify_session_redirect_path
+ redirect_uri = request.path_info
+ redirect_uri += "?#{request.query_string}" if request.query_string.present?
+ redirect_uri
+ end
+
+ included do
+ private
+
+ def redirect_to_verify
+ session[:redirect_uri] = verify_session_redirect_path
+ redirect_to verify_session_path
+ end
+
+ def session_verified
+ session[:verified_user] = current_user.id
+ session[:verification] = Gemcutter::PASSWORD_VERIFICATION_EXPIRY.from_now
+ end
+
+ def verified_session_active?
+ session[:verification] &&
+ session[:verification] > Time.current &&
+ session.fetch(:verified_user, "") == current_user.id
+ end
+ end
+end
diff --git a/app/controllers/concerns/webauthn_verifiable.rb b/app/controllers/concerns/webauthn_verifiable.rb
index 3bd4241b0f7..956d8c699b9 100644
--- a/app/controllers/concerns/webauthn_verifiable.rb
+++ b/app/controllers/concerns/webauthn_verifiable.rb
@@ -15,6 +15,11 @@ def setup_webauthn_authentication(form_url:, session_options: {})
def webauthn_credential_verified?
@credential = WebAuthn::Credential.from_get(credential_params)
+ unless user_webauthn_credential
+ @webauthn_error = t("credentials_required")
+ return false
+ end
+
@credential.verify(
challenge,
public_key: user_webauthn_credential.public_key,
@@ -22,6 +27,11 @@ def webauthn_credential_verified?
)
user_webauthn_credential.update!(sign_count: @credential.sign_count)
+ if @credential.user_handle.present? && @credential.user_handle != user_webauthn_credential.user.webauthn_id
+ @webauthn_error = t("credentials_required")
+ return false
+ end
+
true
rescue WebAuthn::Error => e
@webauthn_error = e.message
@@ -35,8 +45,16 @@ def webauthn_credential_verified?
private
+ def webauthn_credential_scope
+ if @user.present?
+ @user.webauthn_credentials
+ else
+ User.find_by(webauthn_id: @credential.user_handle)&.webauthn_credentials || WebauthnCredential.none
+ end
+ end
+
def user_webauthn_credential
- @user_webauthn_credential ||= @user.webauthn_credentials.find_by(
+ @user_webauthn_credential ||= webauthn_credential_scope.find_by(
external_id: @credential.id
)
end
@@ -50,7 +68,8 @@ def credential_params
:id,
:type,
:rawId,
- response: %i[authenticatorData attestationObject clientDataJSON signature]
+ :authenticatorAttachment,
+ response: %i[authenticatorData attestationObject clientDataJSON signature userHandle]
)
end
end
diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb
index c2671b13bf7..6e23b8c6df8 100644
--- a/app/controllers/dashboards_controller.rb
+++ b/app/controllers/dashboards_controller.rb
@@ -25,7 +25,7 @@ def authenticate_with_api_key
hashed_key = Digest::SHA256.hexdigest(params_key)
return unless (@api_key = ApiKey.unexpired.find_by_hashed_key(hashed_key))
- set_tags "gemcutter.user.id" => @api_key.user_id, "gemcutter.user.api_key_id" => @api_key.id
+ set_tags "gemcutter.api_key.owner" => @api_key.owner.to_gid, "gemcutter.api_key" => @api_key.to_gid
render plain: "An invalid API key cannot be used. Please delete it and create a new one.", status: :forbidden if @api_key.soft_deleted?
end
diff --git a/app/controllers/oidc/api_key_roles_controller.rb b/app/controllers/oidc/api_key_roles_controller.rb
index aa7e4b985a3..5e0c34ce610 100644
--- a/app/controllers/oidc/api_key_roles_controller.rb
+++ b/app/controllers/oidc/api_key_roles_controller.rb
@@ -1,12 +1,11 @@
class OIDC::ApiKeyRolesController < ApplicationController
include ApiKeyable
+ include SessionVerifiable
+ verify_session_before
+
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
@@ -90,17 +89,23 @@ def destroy
private
+ def verify_session_redirect_path
+ case action_name
+ when "create"
+ new_profile_api_key_path
+ when "update"
+ edit_profile_api_key_path
+ else
+ super
+ end
+ end
+
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
diff --git a/app/controllers/oidc/concerns/trusted_publisher_creation.rb b/app/controllers/oidc/concerns/trusted_publisher_creation.rb
new file mode 100644
index 00000000000..480c4e5c9f8
--- /dev/null
+++ b/app/controllers/oidc/concerns/trusted_publisher_creation.rb
@@ -0,0 +1,21 @@
+module OIDC::Concerns::TrustedPublisherCreation
+ extend ActiveSupport::Concern
+
+ included do
+ include SessionVerifiable
+ verify_session_before
+
+ before_action :set_trusted_publisher_type, only: %i[create]
+ before_action :create_params, only: %i[create]
+ before_action :set_page, only: :index
+ end
+
+ def set_trusted_publisher_type
+ trusted_publisher_type = params.permit(create_params_key => :trusted_publisher_type).require(create_params_key).require(:trusted_publisher_type)
+
+ @trusted_publisher_type = OIDC::TrustedPublisher.all.find { |type| type.polymorphic_name == trusted_publisher_type }
+
+ return if @trusted_publisher_type
+ redirect_back fallback_location: root_path, flash: { error: t("oidc.trusted_publisher.unsupported_type") }
+ end
+end
diff --git a/app/controllers/oidc/id_tokens_controller.rb b/app/controllers/oidc/id_tokens_controller.rb
index c5e98a8a372..1447457a4de 100644
--- a/app/controllers/oidc/id_tokens_controller.rb
+++ b/app/controllers/oidc/id_tokens_controller.rb
@@ -3,10 +3,9 @@
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?
+ include SessionVerifiable
+ verify_session_before
+
before_action :find_id_token, except: %i[index]
before_action :set_page, only: :index
@@ -26,9 +25,4 @@ def show
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/pending_trusted_publishers_controller.rb b/app/controllers/oidc/pending_trusted_publishers_controller.rb
new file mode 100644
index 00000000000..b1e5f10c0b9
--- /dev/null
+++ b/app/controllers/oidc/pending_trusted_publishers_controller.rb
@@ -0,0 +1,65 @@
+class OIDC::PendingTrustedPublishersController < ApplicationController
+ include OIDC::Concerns::TrustedPublisherCreation
+
+ before_action :find_pending_trusted_publisher, only: %i[destroy]
+
+ def index
+ trusted_publishers = current_user
+ .oidc_pending_trusted_publishers.unexpired.includes(:trusted_publisher)
+ .order(:rubygem_name, :created_at).page(@page).strict_loading
+ render OIDC::PendingTrustedPublishers::IndexView.new(
+ trusted_publishers:
+ )
+ end
+
+ def new
+ pending_trusted_publisher = current_user.oidc_pending_trusted_publishers.new(trusted_publisher: OIDC::TrustedPublisher::GitHubAction.new)
+ render OIDC::PendingTrustedPublishers::NewView.new(
+ pending_trusted_publisher:
+ )
+ end
+
+ def create
+ trusted_publisher = current_user.oidc_pending_trusted_publishers.new(
+ create_params.merge(
+ expires_at: 12.hours.from_now
+ )
+ )
+
+ if trusted_publisher.save
+ redirect_to profile_oidc_pending_trusted_publishers_path, flash: { notice: t(".success") }
+ else
+ flash.now[:error] = trusted_publisher.errors.full_messages.to_sentence
+ render OIDC::PendingTrustedPublishers::NewView.new(
+ pending_trusted_publisher: trusted_publisher
+ ), status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ if @pending_trusted_publisher.destroy
+ redirect_to profile_oidc_pending_trusted_publishers_path, flash: { notice: t(".success") }
+ else
+ redirect_back fallback_location: profile_oidc_pending_trusted_publishers_path,
+ flash: { error: @pending_trusted_publisher.errors.full_messages.to_sentence }
+ end
+ end
+
+ private
+
+ def create_params
+ params.permit(
+ create_params_key => [
+ :rubygem_name,
+ :trusted_publisher_type,
+ { trusted_publisher_attributes: @trusted_publisher_type.permitted_attributes }
+ ]
+ ).require(create_params_key)
+ end
+
+ def create_params_key = :oidc_pending_trusted_publisher
+
+ def find_pending_trusted_publisher
+ @pending_trusted_publisher = current_user.oidc_pending_trusted_publishers.find(params.require(:id))
+ end
+end
diff --git a/app/controllers/oidc/providers_controller.rb b/app/controllers/oidc/providers_controller.rb
index d8eef9deb80..1e7d6b39899 100644
--- a/app/controllers/oidc/providers_controller.rb
+++ b/app/controllers/oidc/providers_controller.rb
@@ -1,10 +1,9 @@
# 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?
+ include SessionVerifiable
+ verify_session_before
+
before_action :find_provider, except: %i[index]
before_action :set_page, only: :index
@@ -22,9 +21,4 @@ def show
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/controllers/oidc/rubygem_trusted_publishers_controller.rb b/app/controllers/oidc/rubygem_trusted_publishers_controller.rb
new file mode 100644
index 00000000000..15761d1aa80
--- /dev/null
+++ b/app/controllers/oidc/rubygem_trusted_publishers_controller.rb
@@ -0,0 +1,80 @@
+class OIDC::RubygemTrustedPublishersController < ApplicationController
+ include OIDC::Concerns::TrustedPublisherCreation
+
+ before_action :find_rubygem
+ before_action :render_forbidden, unless: :owner?
+ before_action :find_rubygem_trusted_publisher, except: %i[index new create]
+
+ def index
+ render OIDC::RubygemTrustedPublishers::IndexView.new(
+ rubygem: @rubygem,
+ trusted_publishers: @rubygem.oidc_rubygem_trusted_publishers.includes(:trusted_publisher).page(@page).strict_loading
+ )
+ end
+
+ def new
+ render OIDC::RubygemTrustedPublishers::NewView.new(
+ rubygem_trusted_publisher: @rubygem.oidc_rubygem_trusted_publishers.new(trusted_publisher: gh_actions_trusted_publisher)
+ )
+ end
+
+ def create
+ trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.new(
+ create_params
+ )
+
+ if trusted_publisher.save
+ redirect_to rubygem_trusted_publishers_path(@rubygem.slug), flash: { notice: t(".success") }
+ else
+ flash.now[:error] = trusted_publisher.errors.full_messages.to_sentence
+ render OIDC::RubygemTrustedPublishers::NewView.new(
+ rubygem_trusted_publisher: trusted_publisher
+ ), status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ if @rubygem_trusted_publisher.destroy
+ redirect_to rubygem_trusted_publishers_path(@rubygem.slug), flash: { notice: t(".success") }
+ else
+ redirect_back fallback_location: rubygem_trusted_publishers_path(@rubygem.slug),
+ flash: { error: @rubygem_trusted_publisher.errors.full_messages.to_sentence }
+ end
+ end
+
+ private
+
+ def create_params
+ params.permit(
+ create_params_key => [
+ :trusted_publisher_type,
+ { trusted_publisher_attributes: @trusted_publisher_type.permitted_attributes }
+ ]
+ ).require(create_params_key)
+ end
+
+ def create_params_key = :oidc_rubygem_trusted_publisher
+
+ def find_rubygem_trusted_publisher
+ @rubygem_trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.find(params.require(:id))
+ end
+
+ def gh_actions_trusted_publisher
+ github_params = helpers.github_params(@rubygem)
+
+ publisher = OIDC::TrustedPublisher::GitHubAction.new
+ if github_params
+ publisher.repository_owner = github_params[:user]
+ publisher.repository_name = github_params[:repo]
+ publisher.workflow_filename = workflow_filename(publisher.repository)
+ end
+ publisher
+ end
+
+ def workflow_filename(repo)
+ paths = Octokit.contents(repo, path: ".github/workflows").lazy.select { _1.type == "file" }.map(&:name).grep(/\.ya?ml\z/)
+ paths.max_by { |path| [path.include?("release"), path.include?("push")].map! { (_1 && 1) || 0 } }
+ rescue Octokit::NotFound
+ nil
+ end
+end
diff --git a/app/controllers/owners_controller.rb b/app/controllers/owners_controller.rb
index bec7639f0e7..fe530b1d09e 100644
--- a/app/controllers/owners_controller.rb
+++ b/app/controllers/owners_controller.rb
@@ -1,7 +1,9 @@
class OwnersController < ApplicationController
+ include SessionVerifiable
+
before_action :find_rubygem, except: :confirm
before_action :render_forbidden, unless: :owner?, except: %i[confirm resend_confirmation]
- before_action :redirect_to_verify, unless: :password_session_active?, only: %i[index create destroy]
+ verify_session_before only: %i[index create destroy]
before_action :verify_mfa_requirement, only: %i[create destroy]
def confirm
@@ -53,9 +55,8 @@ def destroy
private
- def redirect_to_verify
- session[:redirect_uri] = rubygem_owners_url(@rubygem.slug)
- redirect_to verify_session_path
+ def verify_session_redirect_path
+ rubygem_owners_url(params[:rubygem_id])
end
def token_params
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index 13304eaaf80..250b2578913 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -1,10 +1,17 @@
class PasswordsController < Clearance::PasswordsController
include MfaExpiryMethods
include WebauthnVerifiable
+ include SessionVerifiable
before_action :validate_confirmation_token, only: %i[edit otp_edit webauthn_edit]
after_action :delete_mfa_expiry_session, only: %i[otp_edit webauthn_edit]
+ # By default, clearance expects the token to be submitted with the password update.
+ # We already invalidated the token when the user became verified by token(+mfa).
+ skip_before_action :ensure_existing_user, only: %i[update]
+ # Instead of the token, we now require the user to have been verified recently.
+ verify_session_before only: %i[update]
+
def edit
if @user.mfa_enabled?
@otp_verification_url = otp_edit_user_password_url(@user, token: @user.confirmation_token)
@@ -14,17 +21,16 @@ def edit
render template: "multifactor_auths/prompt"
else
+ # When user doesn't have mfa, a valid token is a full "magic link" sign in.
+ verified_sign_in
render template: "passwords/edit"
end
end
def update
- @user = find_user_for_update
-
- if @user.update_password password_from_password_reset_params
- @user.reset_api_key! if reset_params[:reset_api_key] == "true"
- @user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true"
- sign_in @user
+ if current_user.update_password password_from_password_reset_params
+ current_user.reset_api_key! if reset_params[:reset_api_key] == "true"
+ current_user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true"
redirect_to url_after_update
session[:password_reset_token] = nil
else
@@ -35,6 +41,8 @@ def update
def otp_edit
if otp_edit_conditions_met?
+ # When the user identified by the email token submits adequate totp, they are logged in
+ verified_sign_in
render template: "passwords/edit"
elsif !session_active?
login_failure(t("multifactor_auths.session_expired"))
@@ -51,11 +59,20 @@ def webauthn_edit
return login_failure(@webauthn_error) unless webauthn_credential_verified?
+ # When the user identified by the email token submits verified webauthn, they are logged in
+ verified_sign_in
render template: "passwords/edit"
end
private
+ def verified_sign_in
+ sign_in @user
+ session_verified
+ @user.update!(confirmation_token: nil)
+ StatsD.increment "login.success"
+ end
+
def url_after_update
dashboard_path
end
@@ -81,4 +98,9 @@ def login_failure(message)
flash.now.alert = message
render template: "multifactor_auths/prompt", status: :unauthorized
end
+
+ def redirect_to_verify
+ session[:redirect_uri] = verify_session_redirect_path
+ redirect_to verify_session_path, alert: t("verification_expired")
+ end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 50f7929d87c..166cacdbd85 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -14,6 +14,10 @@ def show
@extra_rubygems = rubygems
end
+ def me
+ redirect_to profile_path(current_user.display_id)
+ end
+
def edit
@user = current_user
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 2077962bd6e..d696234a435 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,12 +1,15 @@
class SessionsController < Clearance::SessionsController
include MfaExpiryMethods
include WebauthnVerifiable
+ include SessionVerifiable
- before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify authenticate]
- before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: %i[verify authenticate]
- before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, only: %i[verify authenticate]
- before_action :ensure_not_blocked, only: :create
- after_action :delete_mfa_session, only: %i[webauthn_create otp_create]
+ before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify webauthn_authenticate authenticate]
+ before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: %i[verify webauthn_authenticate authenticate]
+ before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, only: %i[verify webauthn_authenticate authenticate]
+ before_action :ensure_not_blocked, only: %i[create webauthn_full_create]
+ before_action :webauthn_new_setup, only: :new
+ after_action :delete_mfa_session, only: %i[webauthn_create webauthn_full_create otp_create]
+ after_action :delete_session_verification, only: :destroy
def create
@user = find_user
@@ -39,6 +42,14 @@ def webauthn_create
do_login
end
+ def webauthn_full_create
+ return login_failure(@webauthn_error) unless webauthn_credential_verified?
+
+ @user = user_webauthn_credential.user
+
+ do_login
+ end
+
def otp_create
@user = User.find(session[:mfa_user])
@@ -54,21 +65,39 @@ def otp_create
end
def verify
+ @user = current_user
+ setup_webauthn_authentication(form_url: webauthn_authenticate_session_path)
end
def authenticate
+ @user = current_user
if verify_user
- session[:verified_user] = current_user.id
- session[:verification] = Time.current + Gemcutter::PASSWORD_VERIFICATION_EXPIRY
- redirect_to session.delete(:redirect_uri) || root_path
+ mark_verified
else
flash.now[:alert] = t("profiles.request_denied")
+ setup_webauthn_authentication(form_url: webauthn_authenticate_session_path)
+ render :verify, status: :unauthorized
+ end
+ end
+
+ def webauthn_authenticate
+ @user = current_user
+ if webauthn_credential_verified?
+ mark_verified
+ else
+ flash.now[:alert] = @webauthn_error
+ setup_webauthn_authentication(form_url: webauthn_authenticate_session_path)
render :verify, status: :unauthorized
end
end
private
+ def mark_verified
+ session_verified
+ redirect_to session.delete(:redirect_uri) || root_path
+ end
+
def verify_user
current_user.authenticated? verify_password_params[:password]
end
@@ -82,7 +111,7 @@ def do_login
if status.success?
StatsD.increment "login.success"
set_login_flash
- redirect_back_or(url_after_create)
+ redirect_to(url_after_create)
else
login_failure(status.failure_message)
end
@@ -92,6 +121,7 @@ def do_login
def login_failure(message)
StatsD.increment "login.failure"
flash.now.notice = message
+ webauthn_new_setup
render "sessions/new", status: :unauthorized
end
@@ -134,6 +164,7 @@ def ensure_not_blocked
return unless user&.blocked_email
flash.now.alert = t(".account_blocked")
+ webauthn_new_setup
render template: "sessions/new", status: :unauthorized
end
@@ -154,4 +185,20 @@ def record_mfa_login_duration(mfa_type:)
StatsD.distribution("login.mfa.#{mfa_type}.duration", duration)
end
+
+ def webauthn_new_setup
+ @webauthn_options = WebAuthn::Credential.options_for_get(
+ user_verification: "discouraged"
+ )
+
+ @webauthn_verification_url = webauthn_full_create_session_path
+
+ session[:webauthn_authentication] = {
+ "challenge" => @webauthn_options.challenge
+ }
+ end
+
+ def delete_session_verification
+ session[:verified_user] = session[:verification] = nil
+ end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index ade0c2215fa..d7ae97308f6 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,14 +1,16 @@
-class UsersController < Clearance::UsersController
+class UsersController < ApplicationController
+ before_action :redirect_to_root, if: :signed_in?
+
def new
- @user = user_from_params
+ @user = User.new
end
def create
- @user = user_from_params
+ @user = User.new(user_params)
if @user.save
Mailer.email_confirmation(@user).deliver_later
flash[:notice] = t(".email_sent")
- redirect_back_or url_after_create
+ redirect_back_or_to root_path
else
render template: "users/new"
end
@@ -17,6 +19,16 @@ def create
private
def user_params
- params.permit(user: Array(User::PERMITTED_ATTRS)).fetch(:user, {})
+ params.require(:user).permit(
+ :bio,
+ :email,
+ :handle,
+ :public_email,
+ :location,
+ :password,
+ :website,
+ :twitter_username,
+ :full_name
+ )
end
end
diff --git a/app/helpers/rubygems_helper.rb b/app/helpers/rubygems_helper.rb
index 960adedfa2b..c0caeeff740 100644
--- a/app/helpers/rubygems_helper.rb
+++ b/app/helpers/rubygems_helper.rb
@@ -93,6 +93,10 @@ 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 rubygem_trusted_publishers_link(rubygem)
+ link_to t("rubygems.aside.links.trusted_publishers"), rubygem_trusted_publishers_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)
@@ -135,6 +139,15 @@ def link_to_user(user)
alt: user.display_handle, title: user.display_handle
end
+ def link_to_pusher(api_key_owner)
+ case api_key_owner
+ when OIDC::TrustedPublisher::GitHubAction
+ image_tag "github_icon.png", width: 48, height: 48, theme: :light, alt: "GitHub", title: api_key_owner.name
+ else
+ raise ArgumentError, "unknown api_key_owner type #{api_key_owner.class}"
+ end
+ end
+
def nice_date_for(time)
time.to_date.to_fs(:long)
end
diff --git a/app/jobs/indexer.rb b/app/jobs/indexer.rb
index 99698e11966..df939032cce 100644
--- a/app/jobs/indexer.rb
+++ b/app/jobs/indexer.rb
@@ -28,9 +28,9 @@ def perform
private
def stringify(value)
- final = StringIO.new
+ final = ActiveSupport::Gzip::Stream.new
gzip = Zlib::GzipWriter.new(final)
- gzip.write(Marshal.dump(value))
+ Marshal.dump(value, gzip)
gzip.close
final.string
diff --git a/app/jobs/refresh_oidc_providers_job.rb b/app/jobs/refresh_oidc_providers_job.rb
new file mode 100644
index 00000000000..c91b336e74e
--- /dev/null
+++ b/app/jobs/refresh_oidc_providers_job.rb
@@ -0,0 +1,9 @@
+class RefreshOIDCProvidersJob < ApplicationJob
+ queue_as :default
+
+ def perform(*_args)
+ OIDC::Provider.find_each do |provider|
+ RefreshOIDCProviderJob.perform_later(provider:)
+ end
+ end
+end
diff --git a/app/jobs/upload_info_file_job.rb b/app/jobs/upload_info_file_job.rb
index c47704fac5b..61d33e4652a 100644
--- a/app/jobs/upload_info_file_job.rb
+++ b/app/jobs/upload_info_file_job.rb
@@ -14,7 +14,7 @@ class UploadInfoFileJob < ApplicationJob
)
def perform(rubygem_name:)
- compact_index_info = GemInfo.new(rubygem_name).compact_index_info
+ compact_index_info = GemInfo.new(rubygem_name, cached: false).compact_index_info
response_body = CompactIndex.info(compact_index_info)
content_md5 = Digest::MD5.base64digest(response_body)
diff --git a/app/jobs/upload_names_file_job.rb b/app/jobs/upload_names_file_job.rb
new file mode 100644
index 00000000000..3e9cc1cd130
--- /dev/null
+++ b/app/jobs/upload_names_file_job.rb
@@ -0,0 +1,42 @@
+class UploadNamesFileJob < ApplicationJob
+ queue_with_priority PRIORITIES.fetch(:push)
+
+ include GoodJob::ActiveJobExtensions::Concurrency
+ good_job_control_concurrency_with(
+ # Maximum number of jobs with the concurrency key to be
+ # concurrently enqueued (excludes performing jobs)
+ #
+ # Because the job only uses current state at time of perform,
+ # it makes no sense to enqueue more than one at a time
+ enqueue_limit: good_job_concurrency_enqueue_limit(default: 1),
+ perform_limit: good_job_concurrency_perform_limit(default: 1),
+ key: name
+ )
+
+ def perform
+ names = GemInfo.ordered_names(cached: false)
+ response_body = CompactIndex.names(names)
+
+ content_md5 = Digest::MD5.base64digest(response_body)
+ checksum_sha256 = Digest::SHA256.base64digest(response_body)
+
+ response = RubygemFs.compact_index.store(
+ "names", response_body,
+ public_acl: false, # the compact-index bucket does not have ACLs enabled
+ metadata: {
+ "surrogate-control" => "max-age=3600, stale-while-revalidate=1800",
+ "surrogate-key" => "names s3-compact-index s3-names",
+ "sha256" => checksum_sha256,
+ "md5" => content_md5
+ },
+ cache_control: "max-age=60, public",
+ content_type: "text/plain; charset=utf-8",
+ checksum_sha256:,
+ content_md5:
+ )
+
+ logger.info(message: "Uploading names file succeeded", response:)
+
+ FastlyPurgeJob.perform_later(key: "s3-names", soft: true)
+ end
+end
diff --git a/app/mailers/mailer.rb b/app/mailers/mailer.rb
index c5f9361c62b..fb59eefd5d5 100644
--- a/app/mailers/mailer.rb
+++ b/app/mailers/mailer.rb
@@ -57,16 +57,28 @@ def notifiers_changed(user_id)
default: "You changed your RubyGems.org email notification settings")
end
- def gem_pushed(pushed_by_user_id, version_id, notified_user_id)
+ def gem_pushed(pushed_by, version_id, notified_user_id)
@version = Version.find(version_id)
notified_user = User.find(notified_user_id)
- @pushed_by_user = User.find(pushed_by_user_id)
+ @pushed_by_user = pushed_by
mail to: notified_user.email,
subject: I18n.t("mailer.gem_pushed.subject", gem: @version.to_title, host: Gemcutter::HOST_DISPLAY,
default: "Gem %{gem} pushed to RubyGems.org")
end
+ def gem_trusted_publisher_added(rubygem_trusted_publisher, created_by_user, notified_user)
+ @rubygem_trusted_publisher = rubygem_trusted_publisher
+ @created_by_user = created_by_user
+ @notified_user = notified_user
+
+ mail to: notified_user.email,
+ subject: I18n.t("mailer.gem_trusted_publisher_added.subject",
+ gem: @rubygem_trusted_publisher.rubygem.name,
+ host: Gemcutter::HOST_DISPLAY,
+ default: "Trusted publisher added to %{gem} on RubyGems.org")
+ end
+
def mfa_notification(user_id)
@user = User.find(user_id)
diff --git a/app/models/api_key.rb b/app/models/api_key.rb
index e34d9357ec9..7d93eeeab80 100644
--- a/app/models/api_key.rb
+++ b/app/models/api_key.rb
@@ -2,15 +2,17 @@ class ApiKey < ApplicationRecord
API_SCOPES = %i[index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks show_dashboard].freeze
APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner remove_owner].freeze
- belongs_to :user
+ belongs_to :owner, polymorphic: true
has_one :api_key_rubygem_scope, dependent: :destroy
has_one :ownership, through: :api_key_rubygem_scope
has_one :oidc_id_token, class_name: "OIDC::IdToken", dependent: :restrict_with_error
- has_one :oidc_api_key_role, through: :oidc_id_token, inverse_of: :api_key
+ has_one :oidc_api_key_role, class_name: "OIDC::ApiKeyRole", through: :oidc_id_token, source: :api_key_role, inverse_of: :api_keys
has_many :pushed_versions, class_name: "Version", inverse_of: :pusher_api_key, foreign_key: :pusher_api_key_id, dependent: :nullify
- validates :user, :name, :hashed_key, presence: true
+ before_validation :set_owner_from_user
+
+ validates :name, :hashed_key, presence: true
validate :exclusive_show_dashboard_scope, if: :can_show_dashboard?
validate :scope_presence
validates :name, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }
@@ -44,6 +46,18 @@ def enabled_scopes
end
end
+ def user
+ owner if user?
+ end
+
+ def user?
+ owner_type == "User"
+ end
+
+ delegate :mfa_required_not_yet_enabled?, :mfa_required_weak_level_enabled?,
+ :mfa_recommended_not_yet_enabled?, :mfa_recommended_weak_level_enabled?,
+ to: :user, allow_nil: true
+
def mfa_authorized?(otp)
return true unless mfa_enabled?
return true if oidc_id_token.present?
@@ -51,6 +65,7 @@ def mfa_authorized?(otp)
end
def mfa_enabled?
+ return false unless user?
return false unless user.mfa_enabled?
user.mfa_ui_and_api? || mfa
end
@@ -115,4 +130,8 @@ def not_expired?
return if changed == %w[expires_at]
errors.add :base, "An expired API key cannot be used. Please create a new one." if expired?
end
+
+ def set_owner_from_user
+ self.owner ||= user
+ end
end
diff --git a/app/models/concerns/user_webauthn_methods.rb b/app/models/concerns/user_webauthn_methods.rb
index fccd9d046cc..add3d95930a 100644
--- a/app/models/concerns/user_webauthn_methods.rb
+++ b/app/models/concerns/user_webauthn_methods.rb
@@ -17,7 +17,7 @@ def webauthn_options_for_create
name: display_id
},
exclude: webauthn_credentials.pluck(:external_id),
- authenticator_selection: { user_verification: "discouraged" }
+ authenticator_selection: { user_verification: "discouraged", resident_key: "preferred" }
)
end
diff --git a/app/models/deletion.rb b/app/models/deletion.rb
index 073576573da..be28a194220 100644
--- a/app/models/deletion.rb
+++ b/app/models/deletion.rb
@@ -1,13 +1,11 @@
class Deletion < ApplicationRecord
- belongs_to :user
+ # we nullify the user when they delete their account
+ belongs_to :user, optional: true
- belongs_to :version, ->(d) { joins(:rubygem).where(platform: d.platform, rubygem: { name: d.rubygem }) },
- class_name: "Version",
- foreign_key: :number,
- primary_key: :number,
- inverse_of: :deletion
+ belongs_to :version, inverse_of: :deletion
- validates :user, :rubygem, :number, presence: true
+ validates :user, presence: true, on: :create
+ validates :rubygem, :number, presence: true
validates :version, presence: true
validate :version_is_indexed, on: :create
validate :metadata_matches_version
@@ -68,6 +66,7 @@ def reindex
Indexer.perform_later
UploadInfoFileJob.perform_later(rubygem_name: rubygem_name)
UploadVersionsFileJob.perform_later
+ UploadNamesFileJob.perform_later
end
def remove_from_storage
diff --git a/app/models/gem_info.rb b/app/models/gem_info.rb
index 3b12936252a..5dc5cdb6350 100644
--- a/app/models/gem_info.rb
+++ b/app/models/gem_info.rb
@@ -1,11 +1,11 @@
class GemInfo
- def initialize(rubygem_name)
+ def initialize(rubygem_name, cached: true)
@rubygem_name = rubygem_name
+ @cached = cached
end
def compact_index_info
- info = Rails.cache.read("info/#{@rubygem_name}")
- if info
+ if @cached && (info = Rails.cache.read("info/#{@rubygem_name}"))
StatsD.increment "compact_index.memcached.info.hit"
info
else
@@ -21,9 +21,8 @@ def info_checksum
Digest::MD5.hexdigest(compact_index_info)
end
- def self.ordered_names
- names = Rails.cache.read("names")
- if names
+ def self.ordered_names(cached: true)
+ if cached && (names = Rails.cache.read("names"))
StatsD.increment "compact_index.memcached.names.hit"
else
StatsD.increment "compact_index.memcached.names.miss"
diff --git a/app/models/oidc/id_token.rb b/app/models/oidc/id_token.rb
index 2e03f5d45ec..e9e3f9f23cd 100644
--- a/app/models/oidc/id_token.rb
+++ b/app/models/oidc/id_token.rb
@@ -1,5 +1,5 @@
class OIDC::IdToken < ApplicationRecord
- belongs_to :api_key_role, class_name: "OIDC::ApiKeyRole", foreign_key: "oidc_api_key_role_id", inverse_of: :id_tokens
+ belongs_to :api_key_role, class_name: "OIDC::ApiKeyRole", foreign_key: :oidc_api_key_role_id, inverse_of: :id_tokens
belongs_to :api_key, inverse_of: :oidc_id_token
has_one :provider, through: :api_key_role, inverse_of: :id_tokens
has_one :user, through: :api_key_role, inverse_of: :oidc_id_tokens
diff --git a/app/models/oidc/pending_trusted_publisher.rb b/app/models/oidc/pending_trusted_publisher.rb
new file mode 100644
index 00000000000..024a11a8c85
--- /dev/null
+++ b/app/models/oidc/pending_trusted_publisher.rb
@@ -0,0 +1,38 @@
+class OIDC::PendingTrustedPublisher < ApplicationRecord
+ belongs_to :user
+ belongs_to :trusted_publisher, polymorphic: true, optional: false
+
+ accepts_nested_attributes_for :trusted_publisher
+
+ validates :rubygem_name,
+ length: { maximum: Gemcutter::MAX_FIELD_LENGTH },
+ presence: true,
+ name_format: true,
+ uniqueness: { case_sensitive: false, scope: %i[trusted_publisher_id trusted_publisher_type], conditions: -> { unexpired } }
+
+ validate :available_rubygem_name, on: :create
+
+ scope :unexpired, -> { where(arel_table[:expires_at].eq(nil).or(arel_table[:expires_at].gt(Time.now.utc))) }
+ scope :expired, -> { where(arel_table[:expires_at].lteq(Time.now.utc)) }
+
+ scope :rubygem_name_is, lambda { |name|
+ sensitive = where(rubygem_name: name.strip).limit(1)
+ return sensitive unless sensitive.empty?
+
+ where("UPPER(rubygem_name) = UPPER(?)", name.strip).limit(1)
+ }
+
+ def build_trusted_publisher(params)
+ self.trusted_publisher = trusted_publisher_type.constantize.build_trusted_publisher(params)
+ end
+
+ private
+
+ def available_rubygem_name
+ return if rubygem_name.blank?
+ rubygem = Rubygem.name_is(rubygem_name).first
+ return if rubygem.nil? || rubygem.pushable?
+
+ errors.add(:rubygem_name, :unavailable)
+ end
+end
diff --git a/app/models/oidc/provider.rb b/app/models/oidc/provider.rb
index 52cc1a6e942..04361fff603 100644
--- a/app/models/oidc/provider.rb
+++ b/app/models/oidc/provider.rb
@@ -39,6 +39,13 @@ def valid?
attribute :jwks, Types::JsonDeserializable.new(JSON::JWK::Set)
+ def trusted_publisher_class
+ case issuer
+ when GITHUB_ACTIONS_ISSUER
+ OIDC::TrustedPublisher::GitHubAction
+ end
+ end
+
private
def issuer_match
diff --git a/app/models/oidc/rubygem_trusted_publisher.rb b/app/models/oidc/rubygem_trusted_publisher.rb
new file mode 100644
index 00000000000..055bf6a6238
--- /dev/null
+++ b/app/models/oidc/rubygem_trusted_publisher.rb
@@ -0,0 +1,12 @@
+class OIDC::RubygemTrustedPublisher < ApplicationRecord
+ belongs_to :rubygem
+ belongs_to :trusted_publisher, polymorphic: true, optional: false
+
+ accepts_nested_attributes_for :trusted_publisher
+
+ validates :rubygem, uniqueness: { scope: %i[trusted_publisher_id trusted_publisher_type] }
+
+ def build_trusted_publisher(params)
+ self.trusted_publisher = trusted_publisher_type.constantize.build_trusted_publisher(params)
+ end
+end
diff --git a/app/models/oidc/trusted_publisher.rb b/app/models/oidc/trusted_publisher.rb
new file mode 100644
index 00000000000..ad9a68da98a
--- /dev/null
+++ b/app/models/oidc/trusted_publisher.rb
@@ -0,0 +1,9 @@
+module OIDC::TrustedPublisher
+ def self.table_name_prefix
+ "oidc_trusted_publisher_"
+ end
+
+ def self.all
+ [GitHubAction]
+ end
+end
diff --git a/app/models/oidc/trusted_publisher/github_action.rb b/app/models/oidc/trusted_publisher/github_action.rb
new file mode 100644
index 00000000000..0a3280ee41c
--- /dev/null
+++ b/app/models/oidc/trusted_publisher/github_action.rb
@@ -0,0 +1,163 @@
+class OIDC::TrustedPublisher::GitHubAction < ApplicationRecord
+ has_many :rubygem_trusted_publishers, class_name: "OIDC::RubygemTrustedPublisher", as: :trusted_publisher, dependent: :destroy,
+ inverse_of: :trusted_publisher
+ has_many :pending_trusted_publishers, class_name: "OIDC::PendingTrustedPublisher", as: :trusted_publisher, dependent: :destroy,
+ inverse_of: :trusted_publisher
+ has_many :rubygems, through: :rubygem_trusted_publishers
+ has_many :api_keys, dependent: :destroy, inverse_of: :owner, as: :owner
+
+ before_validation :find_github_repository_owner_id
+
+ validates :repository_owner, presence: true
+ validates :repository_name, presence: true
+ validates :workflow_filename, presence: true
+ validates :environment, presence: true, allow_nil: true
+ validates :repository_owner_id, presence: true
+
+ validate :unique_publisher
+ validate :workflow_filename_format
+
+ def self.for_claims(claims)
+ repository = claims.fetch(:repository)
+ repository_owner, repository_name = repository.split("/", 2)
+ workflow_prefix = "#{repository}/.github/workflows/"
+ workflow_ref = claims.fetch(:job_workflow_ref).delete_prefix(workflow_prefix)
+ workflow_filename = workflow_ref.sub(/@[^@]+\z/, "")
+
+ required = {
+ repository_owner:, repository_name:, workflow_filename:,
+ repository_owner_id: claims.fetch(:repository_owner_id)
+ }
+
+ base = where(required)
+ if (env = claims[:environment])
+ base.where(environment: env).or(base.where(environment: nil)).order(environment: :asc) # NULLS LAST by default for asc
+ else
+ base.where(environment: nil)
+ end.first!
+ end
+
+ def self.permitted_attributes
+ %i[repository_owner repository_name workflow_filename environment]
+ end
+
+ def self.build_trusted_publisher(params)
+ params = params.reverse_merge(repository_owner_id: nil, repository_name: nil, workflow_filename: nil, environment: nil)
+ params.delete(:environment) if params[:environment].blank?
+ params.delete(:repository_owner_id)
+ find_or_initialize_by(params)
+ end
+
+ def self.publisher_name = "GitHub Actions"
+
+ def repository_condition
+ OIDC::AccessPolicy::Statement::Condition.new(
+ operator: "string_equals",
+ claim: "repository",
+ value: [repository_owner, repository_name].join("/")
+ )
+ end
+
+ def environment_condition
+ return if environment.blank?
+ OIDC::AccessPolicy::Statement::Condition.new(
+ operator: "string_equals",
+ claim: "environment",
+ value: environment
+ )
+ end
+
+ def repository_owner_id_condition
+ OIDC::AccessPolicy::Statement::Condition.new(
+ operator: "string_equals",
+ claim: "repository_owner_id",
+ value: repository_owner_id
+ )
+ end
+
+ def audience_condition
+ OIDC::AccessPolicy::Statement::Condition.new(
+ operator: "string_equals",
+ claim: "aud",
+ value: Gemcutter::HOST
+ )
+ end
+
+ def job_workflow_ref_condition(ref)
+ OIDC::AccessPolicy::Statement::Condition.new(
+ operator: "string_equals",
+ claim: "job_workflow_ref",
+ value: "#{repository}/#{workflow_slug}@#{ref}"
+ )
+ end
+
+ def to_access_policy(jwt)
+ common_conditions = [repository_condition, environment_condition, repository_owner_id_condition, audience_condition].compact
+ refs = [jwt.fetch(:ref), jwt.fetch(:sha)].compact_blank
+ raise OIDC::AccessPolicy::AccessError, "ref and sha are both missing" if refs.empty?
+ OIDC::AccessPolicy.new(
+ statements: refs.map do |ref|
+ OIDC::AccessPolicy::Statement.new(
+ effect: "allow",
+ principal: OIDC::AccessPolicy::Statement::Principal.new(
+ oidc: OIDC::Provider::GITHUB_ACTIONS_ISSUER
+ ),
+ conditions: common_conditions + [job_workflow_ref_condition(ref)]
+ )
+ end
+ )
+ end
+
+ def name
+ name = "#{self.class.publisher_name} #{repository_owner}/#{repository_name} @ #{workflow_slug}"
+ name << " (#{environment})" if environment?
+ name
+ end
+
+ def repository = "#{repository_owner}/#{repository_name}"
+
+ def workflow_slug = ".github/workflows/#{workflow_filename}"
+
+ def owns_gem?(rubygem) = rubygem_trusted_publishers.exists?(rubygem: rubygem)
+
+ def ld_context
+ LaunchDarkly::LDContext.create(
+ key: "#{model_name.singular}-key-#{id}",
+ kind: "trusted_publisher",
+ name: name
+ )
+ end
+
+ private
+
+ def find_github_repository_owner_id
+ return if repository_owner.blank?
+ return if repository_owner_id.present?
+
+ self.repository_owner_id =
+ begin
+ Octokit::Client.new.user(repository_owner).id
+ rescue Octokit::NotFound
+ nil
+ end
+ end
+
+ def unique_publisher
+ return unless self.class.exists?(
+ repository_owner: repository_owner,
+ repository_name: repository_name,
+ repository_owner_id: repository_owner_id,
+ workflow_filename: workflow_filename,
+ environment: environment
+ )
+
+ errors.add(:base, "publisher already exists")
+ end
+
+ def workflow_filename_format
+ return if workflow_filename.blank?
+
+ errors.add(:workflow_filename, "must end with .yml or .yaml") unless /\.ya?ml\z/.match?(workflow_filename)
+ errors.add(:workflow_filename, "must be a filename only, without directories") if workflow_filename.include?("/")
+ end
+end
diff --git a/app/models/pusher.rb b/app/models/pusher.rb
index d6278ee73e1..6d7922a6076 100644
--- a/app/models/pusher.rb
+++ b/app/models/pusher.rb
@@ -4,34 +4,26 @@ class Pusher
include TraceTagger
include SemanticLogger::Loggable
- attr_reader :api_key, :user, :spec, :spec_contents, :message, :code, :rubygem, :body, :version, :version_id, :size
-
- def initialize(api_key, body, remote_ip = "", scoped_rubygem = nil)
- # this is ugly, but easier than updating all the unit tests, for now
- case api_key
- when ApiKey
- @api_key = api_key
- @user = api_key.user
- raise ArgumentError if scoped_rubygem
- scoped_rubygem = api_key.rubygem
- else
- @user = api_key
- end
+ attr_reader :api_key, :owner, :spec, :spec_contents, :message, :code, :rubygem, :body, :version, :version_id, :size
+
+ def initialize(api_key, body, remote_ip = "")
+ @api_key = api_key
+ @owner = api_key.owner
+ @scoped_rubygem = api_key.rubygem
@body = StringIO.new(body.read)
@size = @body.size
@remote_ip = remote_ip
- @scoped_rubygem = scoped_rubygem
end
def process
- trace("gemcutter.pusher.process", tags: { "gemcutter.user.id" => user.id }) do
+ trace("gemcutter.pusher.process", tags: { "gemcutter.api_key.owner" => owner.to_gid }) do
pull_spec && find && authorize && verify_gem_scope && verify_mfa_requirement && validate && save
end
end
def authorize
- rubygem.pushable? || rubygem.owned_by?(user) || notify_unauthorized
+ (rubygem.pushable? && (api_key.user? || find_pending_trusted_publisher)) || owner.owns_gem?(rubygem) || notify_unauthorized
end
def verify_gem_scope
@@ -41,7 +33,7 @@ def verify_gem_scope
end
def verify_mfa_requirement
- user.mfa_enabled? || !(version_mfa_required? || rubygem.metadata_mfa_required?) ||
+ (!api_key.user? || owner.mfa_enabled?) || !(version_mfa_required? || rubygem.metadata_mfa_required?) ||
notify("Rubygem requires owners to enable MFA. You must enable MFA before pushing new version.", 403)
end
@@ -67,11 +59,11 @@ def save
write_gem @body, @spec_contents
end
rescue ArgumentError => e
- @version.destroy
+ @version&.destroy
Rails.error.report(e, handled: true)
notify("There was a problem saving your gem. #{e}", 400)
rescue StandardError => e
- @version.destroy
+ @version&.destroy
Rails.error.report(e, handled: true)
notify("There was a problem saving your gem. Please try again.", 500)
else
@@ -94,7 +86,7 @@ def pull_spec
MSG
end
- def find
+ def find # rubocop:disable Metrics/AbcSize
name = spec.name.to_s
set_tag "gemcutter.rubygem.name", name
@@ -124,7 +116,7 @@ def find
size: size,
sha256: sha256,
spec_sha256: spec_sha256,
- pusher: user,
+ pusher: api_key.user,
pusher_api_key: api_key,
cert_chain: spec.cert_chain
@@ -136,7 +128,7 @@ def find
# Overridden so we don't get megabytes of the raw data printing out
def inspect
- attrs = %i[@rubygem @user @message @code].map do |attr|
+ attrs = %i[@rubygem @owner @message @code].map do |attr|
"#{attr}=#{instance_variable_get(attr).inspect}"
end
""
@@ -147,26 +139,27 @@ def inspect
def after_write
@version_id = version.id
version.rubygem.push_notifiable_owners.each do |notified_user|
- Mailer.gem_pushed(user.id, @version_id, notified_user.id).deliver_later
+ Mailer.gem_pushed(owner, @version_id, notified_user.id).deliver_later
end
Indexer.perform_later
UploadVersionsFileJob.perform_later
UploadInfoFileJob.perform_later(rubygem_name: rubygem.name)
+ UploadNamesFileJob.perform_later
ReindexRubygemJob.perform_later(rubygem:)
GemCachePurger.call(rubygem.name)
StoreVersionContentsJob.perform_later(version:) if ld_variation(key: "gemcutter.pusher.store_version_contents", default: false)
- RackAttackReset.gem_push_backoff(@remote_ip, @user.display_id) if @remote_ip.present?
+ RackAttackReset.gem_push_backoff(@remote_ip, owner.to_gid) if @remote_ip.present?
StatsD.increment "push.success"
end
def ld_variation(key:, default:)
Rails.configuration.launch_darkly_client.variation(
- key, user.ld_context, default
+ key, owner.ld_context, default
)
end
def notify(message, code)
- logger.info { { message:, code:, user: user.id, api_key: api_key&.id, rubygem: rubygem&.name, version: version&.full_name } }
+ logger.info { { message:, code:, owner: owner, api_key: api_key&.id, rubygem: rubygem&.name, version: version&.full_name } }
@message = message
@code = code
@@ -176,7 +169,23 @@ def notify(message, code)
def update
rubygem.disown if rubygem.versions.indexed.count.zero?
rubygem.update_attributes_from_gem_specification!(version, spec)
- rubygem.create_ownership(user)
+
+ if rubygem.unowned?
+ case owner
+ when User
+ rubygem.create_ownership(owner)
+ else
+ pending_publisher = find_pending_trusted_publisher
+ return notify_unauthorized if pending_publisher.blank?
+
+ rubygem.transaction do
+ logger.info { "Reifying pending publisher" }
+ rubygem.create_ownership(pending_publisher.user)
+ owner.rubygem_trusted_publishers.create!(rubygem: rubygem)
+ end
+ end
+ end
+
set_info_checksum
true
@@ -196,18 +205,20 @@ def republish_notification(version)
"Please bump the version number and push a new different release.\n" \
"See also `gem yank` if you want to unpublish the bad release.", 409)
else
- different_owner = "pushed by a previous owner of this gem " unless version.rubygem.owners.include?(@user)
+ different_owner = "pushed by a previous owner of this gem " unless owner.owns_gem?(version.rubygem)
notify("A yanked version #{different_owner}already exists (#{version.full_name}).\n" \
"Repushing of gem versions is not allowed. Please use a new version and retry", 409)
end
end
def notify_unauthorized
- if rubygem.unconfirmed_ownership?(user)
+ if !api_key.user?
+ notify("You are not allowed to push this gem.", 403)
+ elsif rubygem.unconfirmed_ownership?(owner)
notify("You do not have permission to push to this gem. " \
- "Please confirm the ownership by clicking on the confirmation link sent your email #{user.email}", 403)
+ "Please confirm the ownership by clicking on the confirmation link sent your email #{owner.email}", 403)
else
- notify("You do not have permission to push to this gem. Ask an owner to add you with: gem owner #{rubygem.name} --add #{user.email}", 403)
+ notify("You do not have permission to push to this gem. Ask an owner to add you with: gem owner #{rubygem.name} --add #{owner.email}", 403)
end
end
@@ -263,7 +274,7 @@ def log_pushing
}
end
- { message: "Pushing gem", version:, rubygem: @version.rubygem.name, pusher: user.as_json }
+ { message: "Pushing gem", version:, rubygem: @version.rubygem.name, pusher: owner.as_json }
end
end
@@ -294,4 +305,9 @@ def serialize_spec
@spec_contents = Gem.deflate(Marshal.dump(spec))
true
end
+
+ def find_pending_trusted_publisher
+ return unless owner.class.module_parent_name == "OIDC::TrustedPublisher"
+ owner.pending_trusted_publishers.unexpired.rubygem_name_is(rubygem.name).first
+ end
end
diff --git a/app/models/rubygem.rb b/app/models/rubygem.rb
index efcd9d54578..69cb7ae936c 100644
--- a/app/models/rubygem.rb
+++ b/app/models/rubygem.rb
@@ -20,6 +20,7 @@ class Rubygem < ApplicationRecord
has_many :ownership_requests, -> { opened }, dependent: :destroy, inverse_of: :rubygem
has_many :audits, as: :auditable, inverse_of: :auditable
has_many :link_verifications, as: :linkable, inverse_of: :linkable, dependent: :destroy
+ has_many :oidc_rubygem_trusted_publishers, class_name: "OIDC::RubygemTrustedPublisher", inverse_of: :rubygem, dependent: :destroy
validates :name,
length: { maximum: Gemcutter::MAX_FIELD_LENGTH },
@@ -315,8 +316,11 @@ def refresh_indexed!
end
def disown
- ownerships_including_unconfirmed.each(&:delete)
+ ownerships_including_unconfirmed.find_each(&:delete)
ownerships_including_unconfirmed.clear
+
+ oidc_rubygem_trusted_publishers.find_each(&:delete)
+ oidc_rubygem_trusted_publishers.clear
end
def find_version_from_spec(spec)
diff --git a/app/models/user.rb b/app/models/user.rb
index 6d7b4aa5905..cae3bd055b7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -4,18 +4,6 @@ class User < ApplicationRecord
include Gravtastic
is_gravtastic default: "retro"
- PERMITTED_ATTRS = %i[
- bio
- email
- handle
- public_email
- location
- password
- website
- twitter_username
- full_name
- ].freeze
-
before_save :_generate_confirmation_token_no_reset_unconfirmed_email, if: :will_save_change_to_unconfirmed_email?
before_create :_generate_confirmation_token_no_reset_unconfirmed_email
before_destroy :yank_gems
@@ -27,13 +15,14 @@ class User < ApplicationRecord
has_many :subscribed_gems, -> { order("name ASC") }, through: :subscriptions, source: :rubygem
has_many :pushed_versions, -> { by_created_at }, dependent: :nullify, inverse_of: :pusher, class_name: "Version", foreign_key: :pusher_id
+ has_many :yanked_versions, through: :deletions, source: :version, inverse_of: :yanker
has_many :deletions, dependent: :nullify
has_many :web_hooks, dependent: :destroy
# used for deleting unconfirmed ownerships as well on user destroy
has_many :unconfirmed_ownerships, -> { unconfirmed }, dependent: :destroy, inverse_of: :user, class_name: "Ownership"
- has_many :api_keys, dependent: :destroy
+ has_many :api_keys, dependent: :destroy, inverse_of: :owner, as: :owner
has_many :ownership_calls, -> { opened }, dependent: :destroy, inverse_of: :user
has_many :ownership_requests, -> { opened }, dependent: :destroy, inverse_of: :user
@@ -42,7 +31,9 @@ class User < ApplicationRecord
has_many :oidc_api_key_roles, dependent: :nullify, class_name: "OIDC::ApiKeyRole", inverse_of: :user
has_many :oidc_id_tokens, through: :oidc_api_key_roles, class_name: "OIDC::IdToken", inverse_of: :user, source: :id_tokens
- has_many :oidc_providers, through: :oidc_api_key_roles, class_name: "OIDC::Provider", inverse_of: :users, source: :providers
+ has_many :oidc_providers, through: :oidc_api_key_roles, class_name: "OIDC::Provider", inverse_of: :users, source: :provider
+ has_many :oidc_pending_trusted_publishers, class_name: "OIDC::PendingTrustedPublisher", inverse_of: :user, dependent: :destroy
+ has_many :oidc_rubygem_trusted_publishers, through: :rubygems, class_name: "OIDC::RubygemTrustedPublisher"
validates :email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true,
uniqueness: { case_sensitive: false }
@@ -73,9 +64,13 @@ class User < ApplicationRecord
validate :toxic_email_domain, on: :create
def self.authenticate(who, password)
+ # Avoid exceptions when string is invalid in the given encoding, _or_ cannot be converted
+ # to UTF-8.
+ who = who.encode(Encoding::UTF_8)
+
user = find_by_email(who) || find_by(handle: who)
user if user&.authenticated?(password)
- rescue BCrypt::Errors::InvalidHash
+ rescue BCrypt::Errors::InvalidHash, Encoding::UndefinedConversionError
nil
end
@@ -254,6 +249,10 @@ def can_request_ownership?(rubygem)
!rubygem.owned_by?(self) && rubygem.ownership_requestable?
end
+ def owns_gem?(rubygem)
+ rubygem.owned_by?(self)
+ end
+
def ld_context
LaunchDarkly::LDContext.create(
key: "user-key-#{id}",
diff --git a/app/models/version.rb b/app/models/version.rb
index e12c10467ab..89a483f21bf 100644
--- a/app/models/version.rb
+++ b/app/models/version.rb
@@ -9,10 +9,8 @@ class Version < ApplicationRecord # rubocop:disable Metrics/ClassLength
has_one :gem_download, inverse_of: :version, dependent: :destroy
belongs_to :pusher, class_name: "User", inverse_of: false, optional: true
belongs_to :pusher_api_key, class_name: "ApiKey", inverse_of: :pushed_versions, optional: true
- has_one :deletion, ->(v) { where(rubygem: v.rubygem.name, platform: v.platform) },
- dependent: :delete, inverse_of: :version, required: false,
- primary_key: :number,
- foreign_key: :number
+ has_one :deletion, dependent: :delete, inverse_of: :version, required: false
+ has_one :yanker, through: :deletion, source: :user, inverse_of: :yanked_versions, required: false
before_validation :set_canonical_number, if: :number_changed?
before_validation :full_nameify!
@@ -396,10 +394,6 @@ def rubygems_metadata_mfa_required?
ActiveRecord::Type::Boolean.new.cast(metadata["rubygems_mfa_required"])
end
- def yanker
- Deletion.find_by(rubygem: rubygem.name, number: number, platform: platform)&.user unless indexed
- end
-
def prerelease
!!to_gem_version.prerelease?
end
diff --git a/app/policies/api_key_policy.rb b/app/policies/api_key_policy.rb
index bb391458a03..b99a290268c 100644
--- a/app/policies/api_key_policy.rb
+++ b/app/policies/api_key_policy.rb
@@ -6,7 +6,7 @@ def resolve
end
def avo_show?
- Pundit.policy!(user, record.user).avo_show?
+ Pundit.policy!(user, record.owner).avo_show?
end
has_association :api_key_rubygem_scope
diff --git a/app/policies/oidc/pending_trusted_publisher_policy.rb b/app/policies/oidc/pending_trusted_publisher_policy.rb
new file mode 100644
index 00000000000..0c1b670ff8d
--- /dev/null
+++ b/app/policies/oidc/pending_trusted_publisher_policy.rb
@@ -0,0 +1,13 @@
+class OIDC::PendingTrustedPublisherPolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope.all
+ end
+ end
+
+ def avo_index? = rubygems_org_admin?
+ def avo_show? = rubygems_org_admin?
+
+ has_association :rubygem
+ has_association :trusted_publisher
+end
diff --git a/app/policies/oidc/rubygem_trusted_publisher_policy.rb b/app/policies/oidc/rubygem_trusted_publisher_policy.rb
new file mode 100644
index 00000000000..4aced2a2386
--- /dev/null
+++ b/app/policies/oidc/rubygem_trusted_publisher_policy.rb
@@ -0,0 +1,13 @@
+class OIDC::RubygemTrustedPublisherPolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope.all
+ end
+ end
+
+ def avo_index? = rubygems_org_admin?
+ def avo_show? = rubygems_org_admin?
+
+ has_association :rubygem
+ has_association :trusted_publisher
+end
diff --git a/app/policies/oidc/trusted_publisher/github_action_policy.rb b/app/policies/oidc/trusted_publisher/github_action_policy.rb
new file mode 100644
index 00000000000..155fbfb8bcf
--- /dev/null
+++ b/app/policies/oidc/trusted_publisher/github_action_policy.rb
@@ -0,0 +1,16 @@
+class OIDC::TrustedPublisher::GitHubActionPolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope.all
+ end
+ end
+
+ def avo_index? = rubygems_org_admin?
+ def avo_show? = rubygems_org_admin?
+
+ has_association :trusted_publishers
+ has_association :rubygem_trusted_publishers
+ has_association :pending_trusted_publishers
+ has_association :rubygems
+ has_association :api_keys
+end
diff --git a/app/policies/rubygem_policy.rb b/app/policies/rubygem_policy.rb
index 9347951cfcd..73f4fe46ecf 100644
--- a/app/policies/rubygem_policy.rb
+++ b/app/policies/rubygem_policy.rb
@@ -33,4 +33,7 @@ def act_on?
has_association :linkset
has_association :gem_download
has_association :audits
+ has_association :link_verifications
+
+ has_association :oidc_rubygem_trusted_publishers
end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 36c16110a43..c1496a8dbe1 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -18,7 +18,6 @@ def act_on?
rubygems_org_admin?
end
- has_association :webauthn_credentials
has_association :ownerships
has_association :rubygems
has_association :subscriptions
@@ -32,4 +31,6 @@ def act_on?
has_association :pushed_versions
has_association :audits
has_association :oidc_api_key_roles
+ has_association :webauthn_credentials
+ has_association :webauthn_verification
end
diff --git a/app/policies/webauthn_credential_policy.rb b/app/policies/webauthn_credential_policy.rb
new file mode 100644
index 00000000000..88c5442f864
--- /dev/null
+++ b/app/policies/webauthn_credential_policy.rb
@@ -0,0 +1,13 @@
+class WebauthnCredentialPolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope.all
+ end
+ end
+
+ def avo_show?
+ Pundit.policy!(user, record.user).avo_show?
+ end
+
+ has_association :user
+end
diff --git a/app/policies/webauthn_verification_policy.rb b/app/policies/webauthn_verification_policy.rb
new file mode 100644
index 00000000000..bb4b1d36554
--- /dev/null
+++ b/app/policies/webauthn_verification_policy.rb
@@ -0,0 +1,13 @@
+class WebauthnVerificationPolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope.all
+ end
+ end
+
+ def avo_show?
+ Pundit.policy!(user, record.user).avo_show?
+ end
+
+ has_association :user
+end
diff --git a/app/tasks/maintenance/upload_info_files_to_s3_task.rb b/app/tasks/maintenance/upload_info_files_to_s3_task.rb
new file mode 100644
index 00000000000..8810166ecc3
--- /dev/null
+++ b/app/tasks/maintenance/upload_info_files_to_s3_task.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Maintenance::UploadInfoFilesToS3Task < MaintenanceTasks::Task
+ def collection
+ Rubygem.with_versions
+ end
+
+ def process(rubygem)
+ UploadInfoFileJob.perform_later(rubygem_name: rubygem.name)
+ end
+end
diff --git a/app/tasks/maintenance/verify_gem_contents_in_fs_task.rb b/app/tasks/maintenance/verify_gem_contents_in_fs_task.rb
index 1558ed35509..1f5b9b7bf2d 100644
--- a/app/tasks/maintenance/verify_gem_contents_in_fs_task.rb
+++ b/app/tasks/maintenance/verify_gem_contents_in_fs_task.rb
@@ -26,21 +26,8 @@ def collection
def process(version)
logger.tagged(version_id: version.id, name: version.rubygem.name, number: version.number, platform: version.platform) do
- gem_path = "gems/#{version.gem_file_name}"
- spec_path = "quick/Marshal.4.8/#{version.full_name}.gemspec.rz"
-
- expected_checksum = version.sha256
- logger.warn "Version #{version.full_name} has no checksum" if expected_checksum.blank?
-
- gem_contents = RubygemFs.instance.get(gem_path)
- logger.warn "Version #{version.full_name} is missing gem contents" if gem_contents.blank?
-
- logger.warn "#{spec_path} is missing" if RubygemFs.instance.head(spec_path).blank?
-
- return unless gem_contents.present? && expected_checksum.present?
-
- sha256 = Digest::SHA256.base64digest(gem_contents)
- logger.error "#{gem_path} has incorrect checksum (expected #{expected_checksum}, got #{sha256})" if sha256 != expected_checksum
+ validate_checksum(version, "gem", "gems/#{version.gem_file_name}", version.sha256)
+ validate_checksum(version, "spec", "quick/Marshal.4.8/#{version.full_name}.gemspec.rz", version.spec_sha256)
end
end
@@ -60,4 +47,14 @@ def patterns_are_valid
def matches_regexp(collection, field, regexp)
collection.where(collection.arel_table[field].matches_regexp(regexp))
end
+
+ def validate_checksum(version, name, path, expected_checksum)
+ logger.warn "Version #{version.fullname} has no #{name} checksum" if expected_checksum.blank?
+ contents = RubygemFs.instance.get(path)
+ logger.warn "Version #{version.full_name} is missing #{name} contents (#{path})" if contents.blank?
+
+ return unless contents.present? && expected_checksum.present?
+ sha256 = Digest::SHA256.base64digest(contents)
+ logger.error "#{path} has incorrect checksum (expected #{expected_checksum}, got #{sha256})" if sha256 != expected_checksum
+ end
end
diff --git a/app/views/application_view.rb b/app/views/application_view.rb
index a46f657d1bd..42743b7431a 100644
--- a/app/views/application_view.rb
+++ b/app/views/application_view.rb
@@ -10,4 +10,8 @@ class ApplicationView < ApplicationComponent
def title=(title)
@_view_context.instance_variable_set :@title, title
end
+
+ def title_for_header_only=(title)
+ @_view_context.instance_variable_set :@title_for_header_only, title
+ end
end
diff --git a/app/views/components/application_component.rb b/app/views/components/application_component.rb
index b32deb27a7f..8c29603a93c 100644
--- a/app/views/components/application_component.rb
+++ b/app/views/components/application_component.rb
@@ -2,7 +2,28 @@
class ApplicationComponent < Phlex::HTML
include Phlex::Rails::Helpers::Routes
- include ActionView::Helpers::TranslationHelper
+
+ class TranslationHelper
+ include ActionView::Helpers::TranslationHelper
+
+ def initialize(translation_path:)
+ @translation_path = translation_path
+ end
+
+ private
+
+ def scope_key_by_partial(key)
+ return key unless key&.start_with?(".")
+
+ "#{@translation_path}#{key}"
+ end
+ end
+
+ delegate :t, to: "self.class.translation_helper"
+
+ def self.translation_helper
+ @translation_helper ||= TranslationHelper.new(translation_path: translation_path)
+ end
def self.translation_path
@translation_path ||= name&.dup.tap do |n|
@@ -12,12 +33,4 @@ def self.translation_path
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/trusted_publisher/github_action/form_component.rb b/app/views/components/oidc/trusted_publisher/github_action/form_component.rb
new file mode 100644
index 00000000000..a33c72406b7
--- /dev/null
+++ b/app/views/components/oidc/trusted_publisher/github_action/form_component.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class OIDC::TrustedPublisher::GitHubAction::FormComponent < ApplicationComponent
+ extend Dry::Initializer
+
+ option :github_action_form
+
+ def template
+ github_action_form.fields_for :trusted_publisher do |trusted_publisher_form|
+ field trusted_publisher_form, :text_field, :repository_owner, autocomplete: :off
+ field trusted_publisher_form, :text_field, :repository_name, autocomplete: :off
+ field trusted_publisher_form, :text_field, :workflow_filename, autocomplete: :off
+ field trusted_publisher_form, :text_field, :environment, autocomplete: :off, optional: true
+ end
+ end
+
+ private
+
+ def field(form, type, name, optional: false, **opts)
+ form.label name, class: "form__label" do
+ plain form.object.class.human_attribute_name(name)
+ span(class: "t-text--s") { " (#{t('form.optional')})" } if optional
+ end
+ form.send type, name, class: helpers.class_names("form__input", "tw-border tw-border-red-500" => form.object.errors.include?(name)), **opts
+ p(class: "form__field__instructions") { t("oidc.trusted_publisher.github_actions.#{name}_help_html") }
+ end
+end
diff --git a/app/views/components/oidc/trusted_publisher/github_action/table_component.rb b/app/views/components/oidc/trusted_publisher/github_action/table_component.rb
new file mode 100644
index 00000000000..0b9b5f9a990
--- /dev/null
+++ b/app/views/components/oidc/trusted_publisher/github_action/table_component.rb
@@ -0,0 +1,20 @@
+class OIDC::TrustedPublisher::GitHubAction::TableComponent < ApplicationComponent
+ extend Dry::Initializer
+
+ option :github_action
+
+ def template
+ dl(class: "tw-flex tw-flex-col sm:tw-grid sm:tw-grid-cols-2 tw-items-baseline tw-gap-4 full-width overflow-wrap") do
+ dt(class: "adoption__heading ") { "GitHub Repository" }
+ dd { code { github_action.repository } }
+
+ dt(class: "adoption__heading ") { "Workflow Filename" }
+ dd { code { github_action.workflow_filename } }
+
+ if github_action.environment?
+ dt(class: "adoption__heading") { "Environment" }
+ dd { code { github_action.environment } }
+ end
+ end
+ end
+end
diff --git a/app/views/mailer/api_key_created.html.erb b/app/views/mailer/api_key_created.html.erb
index 6e711be7434..d12c13778df 100644
--- a/app/views/mailer/api_key_created.html.erb
+++ b/app/views/mailer/api_key_created.html.erb
@@ -20,7 +20,7 @@
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) %>
+ <%= ApiKey.human_attribute_name(:oidc_api_key_role) %>: <%= link_to(@api_key.oidc_api_key_role.name, profile_oidc_api_key_role_url(@api_key.oidc_api_key_role.token), target: :_blank) %>
<% end %>
diff --git a/app/views/mailer/gem_pushed.html.erb b/app/views/mailer/gem_pushed.html.erb
index d42fb8cf1fc..f922d42bd8f 100644
--- a/app/views/mailer/gem_pushed.html.erb
+++ b/app/views/mailer/gem_pushed.html.erb
@@ -15,10 +15,17 @@
Gem: <%= link_to @version.to_title, rubygem_version_url(@version.rubygem.slug, @version.slug), target: "_blank" %>
+ <% if @pushed_by_user.is_a?(User) %>
Pushed by user:
<%= link_to @pushed_by_user.handle, profile_url(@pushed_by_user.display_id), target: "_blank" %> <%= mail_to(@pushed_by_user.email) if @pushed_by_user.public_email? %>
+ <% else %>
+ Pushed by trusted publisher:
+
+ <%= link_to @pushed_by_user.name, rubygem_trusted_publishers_url(@version.rubygem.slug), target: "_blank" %>
+
+ <% end %>
Pushed at: <%= @version.created_at.to_formatted_s(:rfc822) %>
diff --git a/app/views/mailer/gem_trusted_publisher_added.html.erb b/app/views/mailer/gem_trusted_publisher_added.html.erb
new file mode 100644
index 00000000000..1d844c4782e
--- /dev/null
+++ b/app/views/mailer/gem_trusted_publisher_added.html.erb
@@ -0,0 +1,53 @@
+<% @title = t(".title") %>
+<% @sub_title = @rubygem_trusted_publisher.rubygem.name %>
+
+
+
+
+ |
+
+
+
+
+
+ A gem you have push access to has recently added a new trusted publisher.
+
+
+ Gem: <%= link_to @rubygem_trusted_publisher.rubygem.name, rubygem_url(@rubygem_trusted_publisher.rubygem.slug), target: "_blank" %>
+
+ Trusted publisher: <%= link_to @rubygem_trusted_publisher.trusted_publisher.name, rubygem_trusted_publishers_url(@rubygem_trusted_publisher.rubygem.slug), target: "_blank" %>
+
+ Added by:
+
+ <%= link_to @created_by_user.display_handle, profile_url(@created_by_user.display_id), target: "_blank" %> <%= mail_to(@created_by_user.email) if @created_by_user.public_email? %>
+
+
+ Added at: <%= @rubygem_trusted_publisher.created_at.to_formatted_s(:rfc822) %>
+
+
+ If this new trusted publisher is expected, you do not need to take further action.
+
+ Only if this change is unexpected
+ please take immediate steps to secure your account and gems:
+
+
+ <%= render "compromised_instructions" do %>
+ Remove the trusted publisher reported in this email
+ <% end %>
+
+
+
+ To stop receiving these messages, update your <%= link_to("email notification settings", notifier_url) %>.
+
+
+
+
+
+
+
+
+ |
+ |
+
+
+
diff --git a/app/views/multifactor_auths/_webauthn_prompt.html.erb b/app/views/multifactor_auths/_webauthn_prompt.html.erb
new file mode 100644
index 00000000000..cd3b6ae7735
--- /dev/null
+++ b/app/views/multifactor_auths/_webauthn_prompt.html.erb
@@ -0,0 +1,13 @@
+
+
<%= t("multifactor_auths.prompt.security_device") %>
+
+
<%= t("multifactor_auths.prompt.webauthn_credential_note") %>
+
+ <%= form_tag @webauthn_verification_url, method: :post, class: "js-webauthn-session--form", data: { options: @webauthn_options.to_json } do %>
+
+ <% end %>
+
diff --git a/app/views/multifactor_auths/prompt.html.erb b/app/views/multifactor_auths/prompt.html.erb
index d9d532793b4..2c8c89707e2 100644
--- a/app/views/multifactor_auths/prompt.html.erb
+++ b/app/views/multifactor_auths/prompt.html.erb
@@ -2,19 +2,7 @@
<% if @user.webauthn_enabled?%>
-
-
<%= t(".security_device") %>
-
-
<%= t(".webauthn_credential_note") %>
-
- <%= form_tag @webauthn_verification_url, method: :post, class: "js-webauthn-session--form", data: { options: @webauthn_options.to_json } do %>
-
- <% end %>
-
+ <%= render "multifactor_auths/webauthn_prompt" %>
<% end %>
<% if @user.totp_enabled? || @user.webauthn_only_with_recovery? %>
diff --git a/app/views/oidc/pending_trusted_publishers/index_view.rb b/app/views/oidc/pending_trusted_publishers/index_view.rb
new file mode 100644
index 00000000000..eb25c6fbfa7
--- /dev/null
+++ b/app/views/oidc/pending_trusted_publishers/index_view.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+class OIDC::PendingTrustedPublishers::IndexView < ApplicationView
+ include Phlex::Rails::Helpers::ButtonTo
+ include Phlex::Rails::Helpers::ContentFor
+ include Phlex::Rails::Helpers::DistanceOfTimeInWordsToNow
+ include Phlex::Rails::Helpers::LinkTo
+ extend Dry::Initializer
+
+ option :trusted_publishers
+
+ def template
+ title_content
+
+ div(class: "tw-space-y-2 t-body") do
+ p do
+ t(".description_html")
+ end
+
+ p do
+ button_to t(".create"), new_profile_oidc_pending_trusted_publisher_path, class: "form__submit", method: :get
+ end
+
+ header(class: "gems__header push--s") do
+ p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(trusted_publishers) }
+ end
+
+ div(class: "tw-divide-y") do
+ trusted_publishers.each do |pending_trusted_publisher|
+ trusted_publisher_section(pending_trusted_publisher)
+ end
+ end
+
+ plain helpers.paginate(trusted_publishers)
+ end
+ end
+
+ def title_content
+ self.title_for_header_only = t(".title")
+ content_for :title do
+ h1(class: "t-display page__heading page__heading-small") do
+ plain t(".title")
+ end
+ end
+ end
+
+ def trusted_publisher_section(pending_trusted_publisher)
+ div(class: "tw-border-solid tw-my-4 tw-space-y-2 tw-flex tw-flex-col") do
+ div(class: "sm:tw-flex sm:tw-flex-row tw-gap-4 tw-mt-2") do
+ h3(class: "!tw-mb-0") { pending_trusted_publisher.rubygem_name }
+ button_to(t(".delete"), profile_oidc_pending_trusted_publisher_path(pending_trusted_publisher),
+ method: :delete, class: "form__submit form__submit--small")
+ end
+
+ div(class: "sm:tw-flex sm:tw-flex-row tw-gap-4") do
+ p(class: "!tw-mb-0") { pending_trusted_publisher.trusted_publisher.class.publisher_name }
+ p(class: "!tw-mb-0") do
+ t(".valid_for_html",
+ time_html: helpers.time_tag(pending_trusted_publisher.expires_at,
+distance_of_time_in_words_to_now(pending_trusted_publisher.expires_at)))
+ end
+ end
+
+ render pending_trusted_publisher.trusted_publisher
+ end
+ end
+end
diff --git a/app/views/oidc/pending_trusted_publishers/new_view.rb b/app/views/oidc/pending_trusted_publishers/new_view.rb
new file mode 100644
index 00000000000..5456de9567c
--- /dev/null
+++ b/app/views/oidc/pending_trusted_publishers/new_view.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class OIDC::PendingTrustedPublishers::NewView < ApplicationView
+ include Phlex::Rails::Helpers::LinkTo
+ include Phlex::Rails::Helpers::SelectTag
+ include Phlex::Rails::Helpers::OptionsForSelect
+ include Phlex::Rails::Helpers::FormWith
+
+ extend Dry::Initializer
+
+ option :pending_trusted_publisher
+
+ def template
+ self.title = t(".title")
+
+ div(class: "t-body") do
+ form_with(
+ model: pending_trusted_publisher,
+ url: profile_oidc_pending_trusted_publishers_path
+ ) do |f|
+ f.label :rubygem_name, class: "form__label"
+ f.text_field :rubygem_name, class: "form__input", autocomplete: :off
+ p(class: "form__field__instructions") { t("oidc.trusted_publisher.pending.rubygem_name_help_html") }
+
+ f.label :trusted_publisher_type, class: "form__label"
+ f.select :trusted_publisher_type, OIDC::TrustedPublisher.all.map { |type|
+ [type.publisher_name, type.polymorphic_name]
+ }, {}, class: "form__input form__select"
+
+ render OIDC::TrustedPublisher::GitHubAction::FormComponent.new(
+ github_action_form: f
+ )
+ f.submit class: "form__submit"
+ end
+ end
+ end
+end
diff --git a/app/views/oidc/rubygem_trusted_publishers/concerns/title.rb b/app/views/oidc/rubygem_trusted_publishers/concerns/title.rb
new file mode 100644
index 00000000000..56643847482
--- /dev/null
+++ b/app/views/oidc/rubygem_trusted_publishers/concerns/title.rb
@@ -0,0 +1,18 @@
+module OIDC::RubygemTrustedPublishers::Concerns::Title
+ extend ActiveSupport::Concern
+
+ included do
+ def title_content
+ self.title_for_header_only = t(".title")
+ content_for :title do
+ h1(class: "t-display page__heading page__heading-small") do
+ plain t(".title")
+
+ i(class: "page__subheading page__subheading--block") do
+ t(".subtitle_owner_html", gem_html: helpers.link_to(rubygem.name, rubygem_path(rubygem.slug), class: "t-link t-underline"))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/views/oidc/rubygem_trusted_publishers/index_view.rb b/app/views/oidc/rubygem_trusted_publishers/index_view.rb
new file mode 100644
index 00000000000..c3b4246fcf4
--- /dev/null
+++ b/app/views/oidc/rubygem_trusted_publishers/index_view.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class OIDC::RubygemTrustedPublishers::IndexView < ApplicationView
+ include Phlex::Rails::Helpers::ButtonTo
+ include Phlex::Rails::Helpers::LinkTo
+ include Phlex::Rails::Helpers::ContentFor
+ include OIDC::RubygemTrustedPublishers::Concerns::Title
+ extend Dry::Initializer
+
+ option :rubygem
+ option :trusted_publishers
+
+ def template
+ title_content
+
+ div(class: "tw-space-y-2 t-body") do
+ p do
+ t(".description_html")
+ end
+
+ p do
+ button_to t(".create"), new_rubygem_trusted_publisher_path(rubygem.slug), class: "form__submit", method: :get
+ end
+
+ header(class: "gems__header push--s") do
+ p(class: "gems__meter l-mb-0") { plain helpers.page_entries_info(trusted_publishers) }
+ end
+
+ div(class: "tw-divide-y") do
+ trusted_publishers.each do |rubygem_trusted_publisher|
+ div(class: "tw-border-solid tw-my-4 tw-space-y-4 tw-flex tw-flex-col") do
+ div(class: "sm:tw-flex sm:tw-items-baseline tw-mt-4 tw-gap-2") do
+ h4 { rubygem_trusted_publisher.trusted_publisher.class.publisher_name }
+ button_to(t(".delete"), rubygem_trusted_publisher_path(rubygem.slug, rubygem_trusted_publisher),
+ method: :delete, class: "form__submit form__submit--small")
+ end
+ render rubygem_trusted_publisher.trusted_publisher
+ end
+ end
+ end
+
+ plain helpers.paginate(trusted_publishers)
+ end
+ end
+end
diff --git a/app/views/oidc/rubygem_trusted_publishers/new_view.rb b/app/views/oidc/rubygem_trusted_publishers/new_view.rb
new file mode 100644
index 00000000000..d664c691ca4
--- /dev/null
+++ b/app/views/oidc/rubygem_trusted_publishers/new_view.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class OIDC::RubygemTrustedPublishers::NewView < ApplicationView
+ include Phlex::Rails::Helpers::ContentFor
+ include Phlex::Rails::Helpers::FormWith
+ include Phlex::Rails::Helpers::LinkTo
+ include Phlex::Rails::Helpers::OptionsForSelect
+ include Phlex::Rails::Helpers::SelectTag
+ include OIDC::RubygemTrustedPublishers::Concerns::Title
+
+ extend Dry::Initializer
+
+ option :rubygem_trusted_publisher
+
+ def template
+ title_content
+
+ div(class: "t-body") do
+ form_with(
+ model: rubygem_trusted_publisher,
+ url: rubygem_trusted_publishers_path(rubygem_trusted_publisher.rubygem.slug)
+ ) do |f|
+ f.label :trusted_publisher_type, class: "form__label"
+ f.select :trusted_publisher_type, OIDC::TrustedPublisher.all.map { |type|
+ [type.publisher_name, type.polymorphic_name]
+ }, {}, class: "form__input form__select"
+
+ render OIDC::TrustedPublisher::GitHubAction::FormComponent.new(
+ github_action_form: f
+ )
+ f.submit class: "form__submit"
+ end
+ end
+ end
+
+ delegate :rubygem, to: :rubygem_trusted_publisher
+end
diff --git a/app/views/oidc/trusted_publisher/github_actions/_github_action.html.erb b/app/views/oidc/trusted_publisher/github_actions/_github_action.html.erb
new file mode 100644
index 00000000000..f54667d34a5
--- /dev/null
+++ b/app/views/oidc/trusted_publisher/github_actions/_github_action.html.erb
@@ -0,0 +1 @@
+<%= render OIDC::TrustedPublisher::GitHubAction::TableComponent.new(github_action:) %>
\ No newline at end of file
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb
index fa6d7fc38a4..dea3648b554 100644
--- a/app/views/passwords/edit.html.erb
+++ b/app/views/passwords/edit.html.erb
@@ -1,9 +1,9 @@
<% @title = t('.title') %>
<%= form_for(:password_reset,
- :url => user_password_path(@user, :token => @user.confirmation_token),
+ :url => user_password_path(current_user),
:html => { :method => :put }) do |form| %>
- <%= error_messages_for @user %>
+ <%= error_messages_for current_user %>
<%= form.label :password, "Password", :class => 'form__label' %>
<%= form.password_field :password, autocomplete: 'new-password', class: 'form__input' %>
diff --git a/app/views/rubygems/_aside.html.erb b/app/views/rubygems/_aside.html.erb
index 80040810a4b..e4949661b11 100644
--- a/app/views/rubygems/_aside.html.erb
+++ b/app/views/rubygems/_aside.html.erb
@@ -67,6 +67,7 @@
<%= report_abuse_link(@rubygem) %>
<%= reverse_dependencies_link(@rubygem) %>
<%= ownership_link(@rubygem) if @rubygem.owned_by?(current_user) %>
+ <%= rubygem_trusted_publishers_link(@rubygem) if @rubygem.owned_by?(current_user) %>
<%= oidc_api_key_role_links(@rubygem) if @rubygem.owned_by?(current_user) %>
<%= resend_owner_confirmation_link(@rubygem) if @rubygem.unconfirmed_ownership?(current_user) %>
<%= rubygem_adoptions_link(@rubygem) if @rubygem.owned_by?(current_user) || @rubygem.ownership_requestable?%>
diff --git a/app/views/rubygems/_gem_members.html.erb b/app/views/rubygems/_gem_members.html.erb
index 38a936d189e..90f823be220 100644
--- a/app/views/rubygems/_gem_members.html.erb
+++ b/app/views/rubygems/_gem_members.html.erb
@@ -32,6 +32,11 @@
<%= link_to_user(latest_version.pusher) %>
+ <% elsif latest_version.pusher_api_key&.owner.present? %>
+
<%= t '.pushed_by' %>:
+
+ <%= link_to_pusher(latest_version.pusher_api_key.owner) %>
+
<% end %>
<% if latest_version.yanker.present? %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
index 5081036d195..099daa2ab84 100644
--- a/app/views/sessions/new.html.erb
+++ b/app/views/sessions/new.html.erb
@@ -20,3 +20,5 @@
<%= form.submit t('sign_in'), :data => {:disable_with => t('form_disable_with')}, :class => 'form__submit' %>
<% end %>
+
+<%= render "multifactor_auths/webauthn_prompt" %>
diff --git a/app/views/sessions/verify.html.erb b/app/views/sessions/verify.html.erb
index c9fd01b4d54..490a28fecf1 100644
--- a/app/views/sessions/verify.html.erb
+++ b/app/views/sessions/verify.html.erb
@@ -13,3 +13,6 @@
<%= form.submit t(".confirm"), data: {disable_with: t("form_disable_with")}, class: "form__submit" %>
<% end %>
+<% if @user.webauthn_enabled?%>
+ <%= render "multifactor_auths/webauthn_prompt" %>
+<% end %>
diff --git a/app/views/settings/edit.html.erb b/app/views/settings/edit.html.erb
index 81d239543f9..15f23bb0ee4 100644
--- a/app/views/settings/edit.html.erb
+++ b/app/views/settings/edit.html.erb
@@ -69,6 +69,11 @@
<%= link_to t('api_keys.index.api_keys'), profile_api_keys_path %>
+
+
<%= link_to t('oidc.pending_trusted_publishers.index.title'), profile_oidc_pending_trusted_publishers_path %>
+
Pending trusted publishers allow you to configure trusted publishing before you have pushed the first version of a gem. For more information about how to set up trusted publishing, see the trusted publishing documentation.
+
+
<% if @user.oidc_api_key_roles.any? %>
<%= link_to t('oidc.api_key_roles.index.api_key_roles'), profile_oidc_api_key_roles_path %>
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index ab7ee81ab1a..a09e512efd8 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -7,7 +7,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/controllers/oidc/id_tokens_controller.rb",
- "line": 17,
+ "line": 16,
"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,
@@ -30,7 +30,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/oidc/api_key_roles/show.html.erb",
- "line": 20,
+ "line": 25,
"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": [
@@ -57,6 +57,29 @@
],
"note": ""
},
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "175ed1fa3ddae92efe03e70a1fea7df153ac9bd53edf617e0c70f420f4ac6781",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/controllers/oidc/rubygem_trusted_publishers_controller.rb",
+ "line": 11,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => OIDC::RubygemTrustedPublishers::IndexView.new(:rubygem => Rubygem.find_by_name((params[:rubygem_id] or params[:id])), :trusted_publishers => Rubygem.find_by_name((params[:rubygem_id] or params[:id])).oidc_rubygem_trusted_publishers.includes(:trusted_publisher).page(params[:page].to_i).strict_loading), {})",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "OIDC::RubygemTrustedPublishersController",
+ "method": "index"
+ },
+ "user_input": "params[:page]",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
+ },
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
@@ -110,7 +133,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/controllers/oidc/providers_controller.rb",
- "line": 13,
+ "line": 12,
"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,
@@ -126,6 +149,29 @@
],
"note": ""
},
+ {
+ "warning_type": "Dynamic Render Path",
+ "warning_code": 15,
+ "fingerprint": "c120032e149023b5d6bb95739c27f2d47bfdb64f78b9bccb2e92c7707bd92a78",
+ "check_name": "Render",
+ "message": "Render path contains parameter value",
+ "file": "app/controllers/oidc/pending_trusted_publishers_controller.rb",
+ "line": 11,
+ "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+ "code": "render(action => OIDC::PendingTrustedPublishers::IndexView.new(:trusted_publishers => current_user.oidc_pending_trusted_publishers.unexpired.includes(:trusted_publisher).order(:rubygem_name, :created_at).page(params[:page].to_i).strict_loading), {})",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "OIDC::PendingTrustedPublishersController",
+ "method": "index"
+ },
+ "user_input": "params[:page]",
+ "confidence": "Weak",
+ "cwe_id": [
+ 22
+ ],
+ "note": ""
+ },
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
@@ -133,7 +179,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/oidc/api_key_roles/show.html.erb",
- "line": 16,
+ "line": 21,
"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": [
@@ -167,7 +213,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/controllers/oidc/providers_controller.rb",
- "line": 17,
+ "line": 16,
"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,
@@ -184,6 +230,6 @@
"note": ""
}
],
- "updated": "2023-10-17 10:36:55 -0700",
+ "updated": "2023-11-24 13:29:48 -0600",
"brakeman_version": "6.0.1"
}
diff --git a/config/deploy/production/secrets.ejson b/config/deploy/production/secrets.ejson
index ab6030524a9..4af4d82b5b7 100644
--- a/config/deploy/production/secrets.ejson
+++ b/config/deploy/production/secrets.ejson
@@ -5,7 +5,7 @@
"_type": "Opaque",
"data": {
"secret_key_base": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:i2m4ZdL6cNaw89lU8r22s403O9eEiEDl:NY18SMzycBnJwDS/hYHI3GmxqKDpLgBxGJbFhf3GgRScY2J/QEHCHv+Fl+vahDSeYEbAmgJxTUS/mpeE++FuhnWrGua9dpQpar5hr12X4CzadkQN2ef+YadRmUr97IvD10VBCUn5zjBjZOYiJQdBqwhK73Cr4jf/hWH30RJn6UCuAiPGo5H+KsjlZ7voW/Ep]",
- "database_url": "EJ[1:/aLhumlos/DZXUNQ/3BYiDp1wKyxZQc+Q0KICeK+QnA=:YxFjVTCJUyg0n0jiu0b3f043LJSXT2qk:AaPrjgyz3ZA3DkYNdTwddk8ig2ofmCNj2cmim6aACkHwV14/ujHV6efOslBO4NTX68HnLFhA39PkcyqTzuza/6zw2eJ5y+uL+5BXHcbpcJxl3Sw+e6ueIhDzMyyzltN219rR6ikhy16g0exuI/IteGNfZV5VLMuaYlKHPyGSRTdTge8jZWKym8N8y9+IdqRCVzo0pvvYE/ljViPh2Pre7fTva4kz9FoAA63u4RucRfhS+iQxJ1o=]",
+ "database_url": "EJ[1:uMpyU5gyTfgs0xJhbejdAgqyoj7aF7C2zVoSoa6xZW0=:bx5WnEVeg7OoFJXHH7hv7mJIXrZyZO7M:kwStYHCymICCmujukrkEIULaXy7tm8LMRsAYpEreEot9oGLXVjYqqfg9BI4LeDl/i9SgvQFLTmwsnf3Ujl4xQLFWdeM4yQbTEJI1VFaj3zkZM+eVGGiS+6m/bomqdK/XHcygWEr3q35mygAeo2WPC3j9sPMNJocWYZX5tJHKrUSZ6EHKnXQw7fISNBb5ZgLbI7Jb2q+hR9/zKVAhW74Wf+8pmQ74yovKIX9O7vegEF+2CUw=]",
"aws_access_key_id": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:KeGkWu/d8KKHzHzGpDncf6Nvp2NfVYRQ:YMhI+eSsVUJKoYJCM0zWenmcJ6CtVcLiyz6GHU2V63O6HPFx]",
"aws_secret_access_key": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:u6fuRpUNQaSoDWTFMn/Me+9Kr1Z+93hA:1HEeKUFo2+K5xaf9fjD6VmewC60GIaYZ93JESoOCqpGhybfk3UypbFhp0yNTsHVM7PhH1n1O56M=]",
"honeybadger_api_key": "EJ[1:PdZreInTWXI1FLrFAIe4j7eLOfbTVh6EWWwBL89TMAk=:PvoZeAX52WmcFsCSWiVhSF86SNd3j9SF:z5I4XA0St0psAfod3SCcwPMWYl2d330p]",
diff --git a/config/deploy/staging/secrets.ejson b/config/deploy/staging/secrets.ejson
index b10ed78e960..5fc2c62e43d 100644
--- a/config/deploy/staging/secrets.ejson
+++ b/config/deploy/staging/secrets.ejson
@@ -5,7 +5,7 @@
"_type": "Opaque",
"data": {
"secret_key_base": "EJ[1:d8Qpc6QSyiCh7sBlZ3VzKenIldRrWaFh8nE/ThE6cio=:X57Fr3d7YC/hdbuJYu/xrbglh7C3g2eF:L0jHynvq98zSuGHlVsH2DTTxitG1enTc4T8N0qEIc+wP3cxG/dWVdo5kCRdeufJfi+B1rv38DkKWW4/Cc3QjJQzN9awHVOK9KZcSTUdUqFlm7Kk5fai4FsIPgHTyFQLyTta8iubIrDNuZgpw9t3NZ6KkRwAkFsLLy2TaoICcEEQFgesiyMWVoXhun1kJnbQp]",
- "database_url": "EJ[1:pWdDjbKg+vvJNbzB01oQzYlmXDPVIxoPydn1NtUUPFI=:S/bKqCGClJWQeqxu2qbDKYeP7qwieuJR:L8/fR5V643iQa3LE2Z6YEmCIOELin0joj5OEWBpzSxJF1ItYO0sMlheg1BdNDDT9nxegFjsclScg8MgkM4dAoTv4A6CrnEdqT8Z3N6LPagIeDBG3Wr+GQ4QpO7GhdC1/eEwnnVbANUPjOCbcWMvP4/jg3OlsqGb8poovCnTJaRjTOMY1lPQZAHDsiMfHgHXL70Vc7rnlrpQJe38+ANqMx1HkTNR/na/nq/A=]",
+ "database_url": "EJ[1:G6ITVcjC1yGU1GiOCe5ttJMp2/f4xoLmfPb/NLqgAlg=:ezKujy7N9Evo9aY23beBi377UccDyBQh:RVTtlTpeEwxCJoBSA9kGC+Gw4HicjT7udL0KEq4yTbMYr6qjTxoMfUZS166uEuCZ02iqX1YReTtHfmbRHNzeZgSosJEvdIDZAIvUZNZjtC6U3qM4HOmBfcfbkN9pnwHpVtnJRO1CoJiUjt4ZlXM7hCDa0251NKf/oby27UPy9rrOyJLojwdHCFFDpLGIpyiivXo8+NIzn7gdlGkhelMZqdykn1OgaNbmXOIPgeE=]",
"aws_access_key_id": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:OLx6cKpU43qYM+Fsa8FeC5Bnx0jbIB68:n9U9IUwaWpnbrLIkjwP7erHefKdX1/08kSTvSmBeuq9+0YQ4]",
"aws_secret_access_key": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:uMtyxPC2DC0BgsYRhOWGCdaom6OroJqI:drTaFTjJ2uhfJn+aPgYTQ4hp1GdLS3Os2PfJVn1BevaukzzcsI9xVp2xS0umzutnQbmmdISOgYg=]",
"honeybadger_api_key": "EJ[1:jnrbdGY2s+ZVCF8BStxF4ZGp1yhxk+x/sXxH8x32qmg=:TD1e42m2hCnraaRigNZZZao9WKF9S9Nf:Myj5m7maDkOC2GbolUskZ2F+zmRscHE8]",
diff --git a/config/initializers/datadog.rb b/config/initializers/datadog.rb
index 67a755a616a..2e44a0f4b13 100644
--- a/config/initializers/datadog.rb
+++ b/config/initializers/datadog.rb
@@ -9,7 +9,7 @@
# Enabling datadog functionality
- enabled = (Rails.env.production? || Rails.env.staging?) && ENV["DD_AGENT_HOST"].present? && !defined?(Rails::Console)
+ enabled = !(Rails.env.development? || Rails.env.test?) && ENV["DD_AGENT_HOST"].present? && !defined?(Rails::Console)
c.runtime_metrics.enabled = enabled
c.profiling.enabled = enabled
c.tracing.enabled = enabled
diff --git a/config/initializers/elasticsearch.rb b/config/initializers/elasticsearch.rb
index 08285a6b5df..b95dbfc428a 100644
--- a/config/initializers/elasticsearch.rb
+++ b/config/initializers/elasticsearch.rb
@@ -1,4 +1,5 @@
require 'faraday_middleware/aws_sigv4'
+require 'opensearch-dsl'
port = 9200
if (Rails.env.test? || Rails.env.development?) && Toxiproxy.running?
diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb
index 0e52a1ef4b4..4c3af798491 100644
--- a/config/initializers/good_job.rb
+++ b/config/initializers/good_job.rb
@@ -19,6 +19,12 @@
class: "MfaUsageStatsJob",
set: { priority: 10 },
description: "Sending MFA usage metrics to statsd every hour"
+ },
+ refresh_oidc_providers: {
+ cron: "every 30m",
+ class: "RefreshOIDCProvidersJob",
+ set: { priority: 10 },
+ description: "Refreshing all OIDC provider configurations every 30m"
}
}
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index fe6f9000289..536b04dbf02 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -90,6 +90,14 @@ def self.api_hashed_key(req)
ApiKey.find_by_hashed_key(hashed_key)
end
+ def self.api_key_owner_id(req)
+ api_key = api_hashed_key(req)
+ return unless api_key
+
+ URI::GID.build(app: GlobalID.app,
+ model_name: api_key.association(:owner).klass.name, model_id: api_key.owner_id).to_s
+ end
+
safelist("assets path") do |req|
req.path.starts_with?("/assets") && req.request_method == "GET"
end
@@ -128,7 +136,7 @@ def self.api_hashed_key(req)
########################### rate limit per api key ###########################
throttle("api/key/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req|
- api_hashed_key(req)&.user&.display_id.presence if protected_route?(protected_api_mfa_actions, req.path, req.request_method)
+ api_key_owner_id(req) if protected_route?(protected_api_mfa_actions, req.path, req.request_method)
end
end
@@ -144,7 +152,7 @@ def self.api_hashed_key(req)
end
throttle("#{PUSH_THROTTLE_PER_USER_KEY}/#{level}", limit: EXP_BASE_REQUEST_LIMIT * level, period: (EXP_BASE_LIMIT_PERIOD**level).seconds) do |req|
- api_hashed_key(req)&.user&.display_id.presence if protected_route?(protected_push_action, req.path, req.request_method)
+ api_key_owner_id(req) if protected_route?(protected_push_action, req.path, req.request_method)
end
end
diff --git a/config/initializers/requires.rb b/config/initializers/requires.rb
index 2103f334848..f238c49c1c6 100644
--- a/config/initializers/requires.rb
+++ b/config/initializers/requires.rb
@@ -1,5 +1,4 @@
require 'rubygems/package'
-require 'rubygems/indexer'
require 'rdoc/markup'
require 'rdoc/markup/to_html'
require 'patterns'
diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb
index 7c930a29733..59a710c0c86 100644
--- a/config/initializers/webauthn.rb
+++ b/config/initializers/webauthn.rb
@@ -1,8 +1,11 @@
WebAuthn.configure do |config|
- config.origin = if Rails.env.development? || Rails.env.test?
+ config.origin = if Rails.env.development?
ENV.fetch("WEBAUTHN_ORIGIN", "http://localhost:3000")
+ elsif Rails.env.test?
+ "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}:31337"
else
- "#{Rails.application.config.rubygems.protocol}://#{Rails.application.config.rubygems.host}"
+ "#{Gemcutter::PROTOCOL}://#{Gemcutter::HOST}"
end
- config.rp_name = "RubyGems.org"
+ config.rp_name = Gemcutter::HOST_DISPLAY
+ # config.rp_id = Gemcutter::HOST
end
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 93455bda012..caae7be98e8 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -3,6 +3,7 @@ de:
credentials_required:
edit: Bearbeiten
failure_when_forbidden:
+ verification_expired:
feed_latest: RubyGems.org | Neueste Gems
feed_subscribed: RubyGems.org | Abonnierte Gems
footer_about_html:
@@ -57,6 +58,10 @@ de:
api_key_role:
oidc/api_key_role:
api_key_permissions:
+ oidc/trusted_publisher/github_action:
+ repository_owner_id:
+ oidc/pending_trusted_publisher:
+ rubygem_name:
errors:
messages:
unpwn:
@@ -73,6 +78,14 @@ de:
taken:
full_name:
taken:
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken:
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable:
models:
user:
activemodel:
@@ -317,6 +330,8 @@ de:
global_html:
gem_text:
gem_html:
+ gem_trusted_publisher_added:
+ title:
news:
show:
title:
@@ -564,6 +579,7 @@ de:
code: Quellcode
docs: Dokumentation
download: Download
+ funding:
header: Links
home: Homepage
mail: Mailingliste
@@ -580,6 +596,7 @@ de:
api_key_role:
name:
new:
+ trusted_publishers:
reserved:
reserved_namespace:
dependencies:
@@ -783,6 +800,42 @@ de:
title:
show:
title:
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other:
@@ -796,3 +849,5 @@ de:
seconds:
other:
one:
+ form:
+ optional:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 72e1c61f935..6edb8565416 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -3,6 +3,7 @@ en:
credentials_required: Credentials required
edit: Edit
failure_when_forbidden: Please double check the URL or try submitting it again.
+ verification_expired: The verification has expired. Please verify again.
feed_latest: RubyGems.org | Latest Gems
feed_subscribed: RubyGems.org | Subscribed Gems
footer_about_html:
@@ -67,6 +68,10 @@ en:
api_key_role: API Key Role
oidc/api_key_role:
api_key_permissions: API Key Permissions
+ oidc/trusted_publisher/github_action:
+ repository_owner_id: GitHub Repository Owner ID
+ oidc/pending_trusted_publisher:
+ rubygem_name: RubyGem name
errors:
messages:
unpwn: has previously appeared in a data breach and should not be used
@@ -83,6 +88,14 @@ en:
taken: "%{value} already exists"
full_name:
taken: "%{value} already exists"
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken: "has already been configured with this trusted publisher"
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable: "is already in use"
models:
user: User
activemodel:
@@ -331,6 +344,8 @@ en:
global_html: This webhook was previously called when
any gem was pushed.
gem_text: This webhook was previously called when %{gem} was pushed.
gem_html: This webhook was previously called when
%{gem} was pushed.
+ gem_trusted_publisher_added:
+ title: TRUSTED PUBLISHER ADDED
news:
show:
title: New Releases — All Gems
@@ -564,6 +579,7 @@ en:
code: Source Code
docs: Documentation
download: Download
+ funding: Funding
header: Links
home: Homepage
mail: Mailing List
@@ -580,6 +596,7 @@ en:
api_key_role:
name: "OIDC: %{name}"
new: "OIDC: Create"
+ trusted_publishers: Trusted publishers
reserved:
reserved_namespace: This namespace is reserved by rubygems.org.
dependencies:
@@ -784,6 +801,49 @@ en:
title: "OIDC ID Tokens"
show:
title: "OIDC ID Token"
+ rubygem_trusted_publishers:
+ index:
+ title: Trusted Publishers
+ subtitle_owner_html: "Trusted publishers for %{gem_html}"
+ delete: Delete
+ create: Create
+ description_html: |
+ Trusted publishers allow you to push gems from CI without storing any long-lived sensitive credentials.
+ For more information about how to set up trusted publishing, see the
trusted publishing documentation.
+ destroy:
+ success: "Trusted Publisher deleted"
+ create:
+ success: "Trusted Publisher created"
+ new:
+ title: "New Trusted Publisher"
+ subtitle_owner_html: "Add a trusted publisher for %{gem_html}"
+ pending_trusted_publishers:
+ index:
+ title: Pending Trusted Publishers
+ valid_for_html: "Valid for %{time_html}"
+ delete: Delete
+ create: Create
+ description_html: |
+ Pending trusted publishers allow you to configure trusted publishing before you have pushed the first version of a gem.
+ For more information about how to set up trusted publishing, see the
trusted publishing documentation.
+ destroy:
+ success: "Pending Trusted Publisher deleted"
+ create:
+ success: "Pending Trusted Publisher created"
+ new:
+ title: "New Pending Trusted Publisher"
+ trusted_publisher:
+ unsupported_type: "Unsupported trusted publisher type"
+ github_actions:
+ repository_owner_help_html: "The GitHub organization name or GitHub username that owns the repository"
+ repository_name_help_html: "The name of the GitHub repository that contains the publishing workflow"
+ workflow_filename_help_html: "The filename of the publishing workflow.
This file should exist in the
.github/workflows/
directory in the repository configured above."
+ environment_help_html: |
+ The name of the
GitHub Actions environment that the above workflow uses for publishing.
+ This should be configured under the repository's settings.
+ While not required, a dedicated publishing environment is strongly encouraged, especially if your repository has maintainers with commit access who shouldn't have RubyGems.org gem push access.
+ pending:
+ rubygem_name_help_html: "The gem (on RubyGems.org) that will be created when this publisher is used"
duration:
minutes:
other: "%{count} minutes"
@@ -797,3 +857,5 @@ en:
seconds:
other: "%{count} seconds"
one: "1 second"
+ form:
+ optional: optional
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 0946aba85e2..147eb0f5f19 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -3,6 +3,7 @@ es:
credentials_required: Credenciales requeridas
edit: Editar
failure_when_forbidden: Por favor verifica la URL o inténtalo nuevamente.
+ verification_expired:
feed_latest: RubyGems.org | Gemas más recientes
feed_subscribed: RubyGems.org | Suscripciones a gemas
footer_about_html: RubyGems.org es el servicio de alojamiento de Gemas de la comunidad
@@ -68,10 +69,16 @@ es:
api_key_role:
oidc/api_key_role:
api_key_permissions:
+ oidc/trusted_publisher/github_action:
+ repository_owner_id:
+ oidc/pending_trusted_publisher:
+ rubygem_name:
errors:
messages:
- unpwn: ha aparecido con anterioridad en una filtración de datos y no se debería usar
- blocked: "El dominio '%{domain}' ha sido bloqueado por spam. Por favor utiliza un email personal válido."
+ unpwn: ha aparecido con anterioridad en una filtración de datos y no se debería
+ usar
+ blocked: El dominio '%{domain}' ha sido bloqueado por spam. Por favor utiliza
+ un email personal válido.
models:
ownership:
attributes:
@@ -84,6 +91,14 @@ es:
taken: "%{value} ya existe"
full_name:
taken: "%{value} ya existe"
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken:
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable:
models:
user: Usuario
activemodel:
@@ -96,15 +111,16 @@ es:
oidc/api_key_permissions:
attributes:
valid_for:
- inclusion: "%{value} segundos debe estar entre 5 minutos (300 segundos) y 1 día (86.400 segundos)"
+ inclusion: "%{value} segundos debe estar entre 5 minutos (300 segundos)
+ y 1 día (86.400 segundos)"
gems:
- too_long: "como mucho puede incluir una gema"
+ too_long: como mucho puede incluir una gema
api_keys:
create:
- success: "Nueva clave de API creada"
+ success: Nueva clave de API creada
invalid_gem: La gema seleccionada no se puede incluir en esta clave
destroy:
- success: "Clave de API eliminada con éxito: %{name}"
+ success: 'Clave de API eliminada con éxito: %{name}'
index:
api_keys: Claves de API
name: Nombre
@@ -125,28 +141,34 @@ es:
access_webhooks: Acceso a webhooks
show_dashboard: Mostrar dashboard
reset: Restablecer
- save_key: "Ten en cuenta que no se volverá a mostrar la clave de API. Nueva clave de API:"
+ save_key: 'Ten en cuenta que no se volverá a mostrar la clave de API. Nueva
+ clave de API:'
mfa: AMF
new:
new_api_key: Nueva clave de API
multifactor_auth: Autenticación multifactor
enable_mfa: Activar AMF
rubygem_scope: Ámbito de aplicación
- rubygem_scope_info: Este alcance restringe los comandos de añadir/eliminar gemas y añadir/eliminar propietarios a una gema específica.
+ rubygem_scope_info: Este alcance restringe los comandos de añadir/eliminar gemas
+ y añadir/eliminar propietarios a una gema específica.
reset:
- success: "Borradas todas las claves de API"
+ success: Borradas todas las claves de API
update:
- success: "Clave de API actualizada con éxito"
- invalid_gem: La gema seleccionada no se puede incluir en el alcance de esta clave
+ success: Clave de API actualizada con éxito
+ invalid_gem: La gema seleccionada no se puede incluir en el alcance de esta
+ clave
edit:
edit_api_key: Editar clave de API
multifactor_auth: Autenticación de múltiples factores
enable_mfa: Activar AMF
rubygem_scope: Ámbito de aplicación
- rubygem_scope_info: Este alcance restringe los comandos de añadir/eliminar gemas y añadir/eliminar propietarios a una gema específica.
- invalid_key: No se puede editar una clave de API inválida. Por favor bórrala y crea una nueva.
+ rubygem_scope_info: Este alcance restringe los comandos de añadir/eliminar gemas
+ y añadir/eliminar propietarios a una gema específica.
+ invalid_key: No se puede editar una clave de API inválida. Por favor bórrala
+ y crea una nueva.
all_gems: Todas las gemas
- gem_ownership_removed: Se ha eliminado la propiedad de %{rubygem_name} tras haber sido añadida a esta clave.
+ gem_ownership_removed: Se ha eliminado la propiedad de %{rubygem_name} tras haber
+ sido añadida a esta clave.
clearance_mailer:
change_password:
title: CAMBIAR CONTRASEÑA
@@ -212,7 +234,7 @@ es:
uptime: Uptime
verified_by: Verificado por
secured_by: Protegido por
- looking_for_maintainers: "Se buscan mantenedores/as"
+ looking_for_maintainers: Se buscan mantenedores/as
header:
dashboard: Dashboard
settings: Configuración
@@ -225,18 +247,21 @@ es:
mailer:
confirm_your_email: Por favor confirma tu dirección de correo con el enlace enviado.
confirmation_subject: Por favor confirma tu dirección de correo con %{host}
- link_expiration_explanation_html: Por favor ten en cuenta que este enlace es válido solo durante 3 horas. Puedes solicitar un enlace actualizado usando la página de
reenvío del correo de confirmación.
+ link_expiration_explanation_html: Por favor ten en cuenta que este enlace es válido
+ solo durante 3 horas. Puedes solicitar un enlace actualizado usando la página
+ de
reenvío del correo
+ de confirmación.
email_confirmation:
title: CONFIRMACIÓN DE EMAIL
subtitle: "¡Último paso!"
confirmation_link: Confirma la dirección de correo
- welcome_message: Bienvenidos a %{host}! Haz clic en el enlace de abajo
- para verificar tu dirección de correo.
+ welcome_message: Bienvenidos a %{host}! Haz clic en el enlace de abajo para
+ verificar tu dirección de correo.
email_reset:
title: CAMBIO DE EMAIL
subtitle: "¡Hola %{handle}!"
- visit_link_instructions: Cambiaste tu dirección de correo en %{host}. Por
- favor visita el siguiente enlace para reactivar tu cuenta.
+ visit_link_instructions: Cambiaste tu dirección de correo en %{host}. Por favor
+ visita el siguiente enlace para reactivar tu cuenta.
deletion_complete:
title: ELIMINACIÓN COMPLETADA
subtitle: "¡Adiós!"
@@ -247,15 +272,15 @@ es:
title: ELIMINACIÓN FALLIDA
subtitle: "¡Lo sentimos!"
subject: Tu solicitud para eliminar tu cuenta de %{host} ha fallado
- body_html: Has solicitado eliminar tu cuenta de %{host}. Lamentablemente,
- no hemos podido procesar tu pedido. Por favor inténtalo de nuevo más adelante
+ body_html: Has solicitado eliminar tu cuenta de %{host}. Lamentablemente, no
+ hemos podido procesar tu pedido. Por favor inténtalo de nuevo más adelante
o %{contact} si el problema persiste.
notifiers_changed:
subject: Cambiaste la configuración de tus notificaciones por email de %{host}
title: NOTIFICACIONES POR EMAIL
subtitle: "¡Hola %{handle}!"
- "on": "ON"
- off_html:
OFF
+ 'on': 'ON'
+ off_html: "
OFF"
gem_pushed:
subject: Gema %{gem} subida a %{host}
title: GEMA SUBIDA
@@ -263,7 +288,7 @@ es:
subject: Gema %{gem} eliminada de %{host}
title: GEMA ELIMINADA
reset_api_key:
- subject: "Clave de API de %{host} restablecida"
+ subject: Clave de API de %{host} restablecida
title: CLAVE DE API RESTABLECIDA
subtitle: "¡Hola %{handle}!"
webauthn_credential_created:
@@ -289,29 +314,44 @@ es:
subject: Por favor confirma la propiedad de la gema %{gem} en %{host}
title: CONFIMACIÓN DE PROPIEDAD
subtitle: "¡Hola %{handle}!"
- body_text: Has sido añadido/a como propietario/a de la gema %{gem} por %{authorizer}. Por favor visita el enlace siguiente para confirmarlo.
- body_html: Has sido añadido/a como propietario/a de la gema
%{gem} por
%{authorizer}. Por favor visita el enlace siguiente para confirmarlo.
- link_expiration_explanation_html: Ten en cuenta que este enlace es válido solo durante %{expiry_hours}. Puedes reenviar el correo de confirmación desde la página de la gema
%{gem} una vez autenticado.
+ body_text: Has sido añadido/a como propietario/a de la gema %{gem} por %{authorizer}.
+ Por favor visita el enlace siguiente para confirmarlo.
+ body_html: Has sido añadido/a como propietario/a de la gema
%{gem}
+ por
%{authorizer}. Por favor visita el enlace siguiente para
+ confirmarlo.
+ link_expiration_explanation_html: Ten en cuenta que este enlace es válido solo
+ durante %{expiry_hours}. Puedes reenviar el correo de confirmación desde la
+ página de la gema
%{gem}
+ una vez autenticado.
owner_added:
subject_self: Has sido añadido/a como propietario/a de la gema %{gem}
- subject_others: El usuario %{owner_handle} ha sido añadido/a como propietario/a de la gema %{gem}
+ subject_others: El usuario %{owner_handle} ha sido añadido/a como propietario/a
+ de la gema %{gem}
title: PROPIETARIO AÑADIDO
subtitle: "¡Hola %{handle}!"
- body_self_html:
Has sido añadido/a como propietario/a de la gema
%{gem} en %{host}.
- body_others_html: El usuario
%{owner_handle} ha sido añadido/a como propietario/a de la gema
%{gem} por
%{authorizer}. Recibes esta notificación por ser propietario de %{gem}.
+ body_self_html:
Has sido añadido/a como propietario/a de la gema
%{gem} en %{host}.
+ body_others_html: El usuario
%{owner_handle} ha sido añadido/a como propietario/a
+ de la gema
%{gem}
+ por
%{authorizer}. Recibes esta notificación por ser propietario de
+ %{gem}.
owner_removed:
subject: Has sido eliminado como propietario/a de la gema %{gem}
title: PROPIETARIO ELIMINADO
subtitle: "¡Hola %{handle}!"
- body_html: Has sido eliminado como propietario/a de la gema
%{gem} en %{host} por
%{remover}.
+ body_html: Has sido eliminado como propietario/a de la gema
%{gem}
+ en %{host} por
%{remover}.
ownerhip_request_closed:
title: CANDIDATURA A PROPIETARIO
subtitle: "¡Hola %{handle}!"
- body_html: Gracias por proponerte como propietario para
%{gem}. Lamentamos informarte de que el dueño de la gema ha cerrado tu solicitud.
+ body_html: Gracias por proponerte como propietario para
%{gem}.
+ Lamentamos informarte de que el dueño de la gema ha cerrado tu solicitud.
ownerhip_request_approved:
- body_html: ¡Enhorabuena! Tu candidatura a propietario de
%{gem} ha sido aprobada. Se te ha añadido a la lista de propietarios de la gema.
+ body_html: "¡Enhorabuena! Tu candidatura a propietario de
%{gem}
+ ha sido aprobada. Se te ha añadido a la lista de propietarios de la gema."
new_opwnership_requests:
- body_html: Hay
%{count} nuevas candidaturas a propietario para
%{gem}. Por favor haz click en el botón siguiente para ver todas las candidaturas.
+ body_html: Hay
%{count} nuevas candidaturas a propietario para
%{gem}.
+ Por favor haz click en el botón siguiente para ver todas las candidaturas.
button: CANDIDATURAS A PROPIETARIO
disable_notifications: Para dejar de recibir estos mensajes actualiza tus
owners_page: PROPIETARIOS
@@ -319,10 +359,13 @@ es:
title: WEBHOOK ELIMINADO
subject: Se ha borrado tu webhook en %{host}
subtitle: "¡Hola %{handle}!"
- body_text: Tu webhook que enviaba peticiones POST a %{url} ha sido eliminado tras %{failures} fallos.
- body_html: Tu webhook que enviaba peticiones
POST
a
%{url}
ha sido eliminado tras %{failures} fallos
+ body_text: Tu webhook que enviaba peticiones POST a %{url} ha sido eliminado
+ tras %{failures} fallos.
+ body_html: Tu webhook que enviaba peticiones
POST
a
%{url}
+ ha sido eliminado tras %{failures} fallos
global_text: Este webhook se ejecutaba antes cuando se subía cualquier gema.
- global_html: Este webhook se ejecutaba antes cuando se subía
cualquier gema.
+ global_html: Este webhook se ejecutaba antes cuando se subía
cualquier
+ gema.
gem_text: Este webhook se ejecutaba antes cuando se subía %{gem}.
gem_html: Este webhook se ejecutaba antes cuando se subía
%{gem}.
web_hook_disabled:
@@ -338,9 +381,12 @@ es:
La última que se ejecutó con éxito fue en %{last_success}
y desde entonces ha fallado %{failures_since_last_success} veces.
Puedes borrar este webhook usando el comando %{delete_command}
.
global_text: Este webhook se ejecutaba antes cuando se subía cualquier gema.
- global_html: Este webhook se ejecutaba antes cuando se subía
cualquier gema.
+ global_html: Este webhook se ejecutaba antes cuando se subía
cualquier
+ gema.
gem_text: Este webhook se ejecutaba antes cuando se subía %{gem}.
gem_html: Este webhook se ejecutaba antes cuando se subía
%{gem}.
+ gem_trusted_publisher_added:
+ title:
news:
show:
title: Nuevos lanzamientos — Todas las Gemas
@@ -351,11 +397,12 @@ es:
pages:
about:
contributors_amount: "%{count} Rubystas"
- downloads_amount: "millones de descargas de gemas"
- checkout_code: "por favor echa un ojo al código"
- mit_licensed: "MIT"
+ downloads_amount: millones de descargas de gemas
+ checkout_code: por favor echa un ojo al código
+ mit_licensed: MIT
logo_header: "¿Buscas nuestro logo?"
- logo_details: Usa el botón de descarga y obtendrás tres archivos .PNG y un .SVG del logo de RubyGems.
+ logo_details: Usa el botón de descarga y obtendrás tres archivos .PNG y un .SVG
+ del logo de RubyGems.
founding_html: El proyecto comenzó en abril del 2009 por %{founder}, y desde
entonces ha crecido para incluir las contribuciones de %{contributors} y %{downloads}.
A partir de RubyGems 1.3.6 el sitio se renombró de Gemcutter a %{title} para
@@ -398,26 +445,41 @@ es:
new:
submit: Restablecer contraseña
title: Cambiar tu contraseña
- will_email_notice: Te enviaremos el enlace para cambiar tu contraseña por correo electrónico.
+ will_email_notice: Te enviaremos el enlace para cambiar tu contraseña por correo
+ electrónico.
multifactor_auths:
incorrect_otp: Tu código OTP no es correcto.
session_expired: Ha expirado tu sesión en la página de acceso.
- require_totp_disabled: La autenticación de múltiples factores basada en OTP ya está activa. Para reconfigurarla debes primero eliminarla.
- require_mfa_enabled: No se ha activado la autenticación de múltiples factores. Primero tienes que activarla.
- require_totp_enabled: No tienes aplicación de autenticación activa. Debes activarla primero.
- require_webauthn_enabled: No tienes ningún dispositivo de seguridad activado. Primero debes asociar un dispositivo a tu cuenta.
- setup_required_html: Por la seguridad de tu cuenta y de tus gemas se te requiere activar la autenticación de múltiples factores.
- Lee por favor el
artículo en nuestro blog para saber más detalles.
- setup_recommended: Por la seguridad de tu cuenta y de tus gemas te animamos a configurar la autenticación de múltiples factores. En el futuro será obligatorio tener AMF activada en tu cuenta.
- strong_mfa_level_required_html: Por la seguridad de tu cuenta y de tus gemas es necesario que cambies el nivel de AMF a "Interfaz de usuario y firma de gemas" o "Interfaz de usuario y API".
- Lee por favor el
artículo en nuestro blog para saber más detalles.
- strong_mfa_level_recommended: Por la seguridad de tu cuenta y de tus gemas te recomendamos que cambies el nivel de AMF a "Interfaz de usuario y firma de gemas" o "Interfaz de usuario y API". En el futuro será obligatorio tener AMF configurada en alguno de esos niveles.
- setup_webauthn_html: 🎉 ¡Ahora soportamos dispositivos de seguridad! Aumenta la seguridad de tu cuenta
configurando un nuevo dispositivo.
+ require_totp_disabled: La autenticación de múltiples factores basada en OTP ya
+ está activa. Para reconfigurarla debes primero eliminarla.
+ require_mfa_enabled: No se ha activado la autenticación de múltiples factores.
+ Primero tienes que activarla.
+ require_totp_enabled: No tienes aplicación de autenticación activa. Debes activarla
+ primero.
+ require_webauthn_enabled: No tienes ningún dispositivo de seguridad activado.
+ Primero debes asociar un dispositivo a tu cuenta.
+ setup_required_html: Por la seguridad de tu cuenta y de tus gemas se te requiere
+ activar la autenticación de múltiples factores. Lee por favor el
artículo
+ en nuestro blog para saber más detalles.
+ setup_recommended: Por la seguridad de tu cuenta y de tus gemas te animamos a
+ configurar la autenticación de múltiples factores. En el futuro será obligatorio
+ tener AMF activada en tu cuenta.
+ strong_mfa_level_required_html: Por la seguridad de tu cuenta y de tus gemas es
+ necesario que cambies el nivel de AMF a "Interfaz de usuario y firma de gemas"
+ o "Interfaz de usuario y API". Lee por favor el
artículo
+ en nuestro blog para saber más detalles.
+ strong_mfa_level_recommended: Por la seguridad de tu cuenta y de tus gemas te
+ recomendamos que cambies el nivel de AMF a "Interfaz de usuario y firma de gemas"
+ o "Interfaz de usuario y API". En el futuro será obligatorio tener AMF configurada
+ en alguno de esos niveles.
+ setup_webauthn_html: "\U0001F389 ¡Ahora soportamos dispositivos de seguridad!
+ Aumenta la seguridad de tu cuenta
configurando
+ un nuevo dispositivo."
new:
title: Activando autenticación de múltiples factores
scan_prompt: Por favor escanea el código QR con tu aplicación de autenticación.
- Si no puedes escanear el código, agrega manualmente la información siguiente a tu
- aplicación.
+ Si no puedes escanear el código, agrega manualmente la información siguiente
+ a tu aplicación.
otp_prompt: Escribe el código de la aplicación de autenticación para continuar.
confirm: He mantenido seguros mis códigos de recuperación.
enable: Activar
@@ -425,8 +487,8 @@ es:
key: 'Clave: %{key}'
time_based: 'Basado en tiempo: Sí'
create:
- qrcode_expired: El código QR y la clave han vencido. Por favor intenta otra vez
- registrar un nuevo dispositivo.
+ qrcode_expired: El código QR y la clave han vencido. Por favor intenta otra
+ vez registrar un nuevo dispositivo.
success: Has activado con éxito la autenticación de múltiples factores.
recovery:
copied: "[ copiado ]"
@@ -434,7 +496,10 @@ es:
title: Códigos de recuperación
copy: "[ copiar ]"
saved: Declaro haber guardado mis códigos de recuperación.
- note_html: "Por favor
copia y guarda estos códigos de recuperación. Puedes usar estos códigos para acceder y restablecer tu autenticación de múltiples factores si pierdes tu dispositivo. Cada código se puede usar una sola vez."
+ note_html: Por favor
copia y guarda
+ estos códigos de recuperación. Puedes usar estos códigos para acceder y restablecer
+ tu autenticación de múltiples factores si pierdes tu dispositivo. Cada código
+ se puede usar una sola vez.
already_generated: Ya deberías haber guardado tus códigos de recuperación.
destroy:
success: Has desactivado exitosamente la autenticación de múltiples factores.
@@ -442,22 +507,26 @@ es:
invalid_level: Nivel de AMF inválido.
success: Has actualizado exitosamente la autenticación de múltiples factores.
prompt:
- webauthn_credential_note: Autentícate con un dispositivo de seguridad como Touch Id, YubiKey, etc.
+ webauthn_credential_note: Autentícate con un dispositivo de seguridad como Touch
+ Id, YubiKey, etc.
sign_in_with_webauthn_credential: Autenticar con dispositivo de seguridad
otp_code: Código OTP
otp_or_recovery: OTP o código de recuperación
recovery_code: Código de recuperación
- recovery_code_html: 'Puedes utilizar un
código de recuperación válido si has perdido el acceso a tu dispositivo de seguridad o de autenticación de múltiples factores.'
+ recovery_code_html: Puedes utilizar un
código de recuperación válido si has perdido el acceso
+ a tu dispositivo de seguridad o de autenticación de múltiples factores.
security_device: Dispositivo de seguridad
verify_code: Verificar código
notifiers:
update:
- success: Has actualizado exitosamente la configuración de tus notificaciones por correo.
+ success: Has actualizado exitosamente la configuración de tus notificaciones
+ por correo.
show:
- info:
- Para ayudar a detectar cambios no autorizados en gemas o en propietarios, te enviamos un correo electrónico
- cada vez que se sube o se elimina una versión de cualquiera de tus gemas o se le añade un nuevo propietario.
- Recibiendo y leyendo esos mensajes ayudas al ecosistema de Ruby.
+ info: Para ayudar a detectar cambios no autorizados en gemas o en propietarios,
+ te enviamos un correo electrónico cada vez que se sube o se elimina una versión
+ de cualquiera de tus gemas o se le añade un nuevo propietario. Recibiendo
+ y leyendo esos mensajes ayudas al ecosistema de Ruby.
'on': Activado
'off': Desactivado
recommended: recomendado
@@ -467,7 +536,8 @@ es:
owner_request_heading: Notificaciones de solicitud de propietarios
push_heading: Notificaciones Push
webauthn_verifications:
- expired_or_already_used: El token del enlace utilizado ha expirado o ya ha sido utilizado.
+ expired_or_already_used: El token del enlace utilizado ha expirado o ya ha sido
+ utilizado.
no_port: No se especifica el puerto. Por favor inténtalo de nuevo.
pending: La autenticación del dispositivo de seguridad está pendiente todavía.
prompt:
@@ -483,11 +553,12 @@ es:
close_browser: Por favor cierra este navegador e inténtalo de nuevo.
owners:
confirm:
- confirmed_email: "Has sido añadido/a como propietario de la gema %{gem}"
- token_expired: El token de confirmación ha expirado. Por favor intenta reenviar el token desde la página de la gema.
+ confirmed_email: Has sido añadido/a como propietario de la gema %{gem}
+ token_expired: El token de confirmación ha expirado. Por favor intenta reenviar
+ el token desde la página de la gema.
index:
add_owner: AÑADIR PROPIETARIO
- name: "PROPIETARIO/A"
+ name: PROPIETARIO/A
mfa: ESTADO DE AMF
status: ESTADO
confirmed_at: CONFIRMADO
@@ -498,21 +569,25 @@ es:
info: añadir o eliminar propietarios
confirmed: Confirmado
pending: Pendiente
- confirm_remove: ¿Seguro que quieres eliminar a este usuario de los propietarios?
+ confirm_remove: "¿Seguro que quieres eliminar a este usuario de los propietarios?"
resend_confirmation:
resent_notice: Se ha reenviado un mensaje de confirmación a tu correo electrónico
create:
- success_notice: "Se ha añadido a %{handle} como propietario sin confirmar. Su acceso como propietario se activará cuando haga click en el mensaje de confirmación que se le ha enviado a su correo"
+ success_notice: Se ha añadido a %{handle} como propietario sin confirmar. Su
+ acceso como propietario se activará cuando haga click en el mensaje de confirmación
+ que se le ha enviado a su correo
destroy:
removed_notice: "%{owner_name} eliminado con éxito de la lista de propietarios"
failed_notice: No se puede eliminar al único propietario de una gema
- mfa_required: La gema tiene activado el requerimiento de AMF, configura AMF en tu cuenta por favor.
+ mfa_required: La gema tiene activado el requerimiento de AMF, configura AMF en
+ tu cuenta por favor.
settings:
edit:
title: Editar configuración
webauthn_credentials: Dispositivo de seguridad
no_webauthn_credentials: No tienes dispositivos de seguridad
- webauthn_credential_note: Un dispositivo de seguridad puede ser cualquier dispositivo que cumpla el estándar FIDO2 como las llaves biométrica y de seguridad.
+ webauthn_credential_note: Un dispositivo de seguridad puede ser cualquier dispositivo
+ que cumpla el estándar FIDO2 como las llaves biométrica y de seguridad.
otp_code: Código OTP o código de recuperación
api_access:
confirm_reset: "¿Seguro? Este cambio no puede deshacerse."
@@ -529,10 +604,16 @@ es:
mfa:
multifactor_auth: Autenticación de múltiples factores
otp: Aplicación de autenticación
- disabled_html: No has activado todavía la autenticación de múltiples factores basada en OTP. Por favor lee la
guía sobre AMF para informarte sobre los distintos niveles de AMF.
+ disabled_html: No has activado todavía la autenticación de múltiples factores
+ basada en OTP. Por favor lee la
guía
+ sobre AMF para informarte sobre los distintos niveles de AMF.
go_settings: Registrar un nuevo dispositivo
- level_html: Has activado la autenticación de múltiples factores. Pincha en "Actualizar" para modificar el nivel de AMF. Por favor lee la
guía sobre AMF para informarte sobre los distintos niveles de AMF.
- enabled_note: Has activado la autenticación de múltiples factores. Para desactivarla usa tu OTP o uno de tus códigos de recuperación activos.
+ level_html: Has activado la autenticación de múltiples factores. Pincha en
+ "Actualizar" para modificar el nivel de AMF. Por favor lee la
guía
+ sobre AMF para informarte sobre los distintos niveles de AMF.
+ enabled_note: Has activado la autenticación de múltiples factores. Para desactivarla
+ usa tu OTP o uno de tus códigos de recuperación activos.
update: Actualizar
disable: Desactivar
enabled: Activado
@@ -545,17 +626,25 @@ es:
ui_and_gem_signin: Interfaz de Usuario y firma de gemas
profiles:
adoptions:
- no_ownership_calls: No has creado llamadas a ser propietario para ninguna de tus gemas
+ no_ownership_calls: No has creado llamadas a ser propietario para ninguna de
+ tus gemas
no_ownership_requests: No has creado ninguna petición para ser propietario
title: Adopción
- subtitle_html: Pide nuevos responsables de mantenimiento o solicita propietarios
(leer más)
+ subtitle_html: Pide nuevos responsables de mantenimiento o solicita propietarios
+
(leer
+ más)
edit:
change_avatar: Cambiar avatar
- disabled_avatar_html: Se usa un avatar por defecto debido a la configuración de privacidad del email. Para usar un
Gravatar personalizado activa la opción 'Mostrar correo electrónico en perfil público'. Ten en cuenta que esto hará público tu correo."
- email_awaiting_confirmation: Por favor confirma tu nueva dirección de correo %{unconfirmed_email}
+ disabled_avatar_html: Se usa un avatar por defecto debido a la configuración
+ de privacidad del email. Para usar un
Gravatar
+ personalizado activa la opción 'Mostrar correo electrónico en perfil público'.
+ Ten en cuenta que esto hará público tu correo."
+ email_awaiting_confirmation: Por favor confirma tu nueva dirección de correo
+ %{unconfirmed_email}
enter_password: Por favor introduce tu contraseña
optional_full_name: Opcional. Será mostrado en tu perfil público
- optional_twitter_username: Usuario de X opcional. Será mostrado en tu perfil público
+ optional_twitter_username: Usuario de X opcional. Será mostrado en tu perfil
+ público
title: Editar perfil
delete:
delete: Eliminar
@@ -600,6 +689,7 @@ es:
code: Código fuente
docs: Documentación
download: Descarga
+ funding: Financiación
header: Enlace
home: Página
mail: Lista de Correo
@@ -614,17 +704,21 @@ es:
ownership: Propietarios
oidc:
api_key_role:
- name: "OIDC: %{name}"
- new: "OIDC: Crear"
+ name: 'OIDC: %{name}'
+ new: 'OIDC: Crear'
+ trusted_publishers:
reserved:
reserved_namespace: Este namespace está reservado por rubygems.org.
dependencies:
header: dependencias de %{title}
gem_members:
authors_header: Autores
- self_no_mfa_warning_html: Considera por favor
activar la autenticación de múltiples factores (AMF) para mantener tu cuenta segura.
- not_using_mfa_warning_show: "* Algunos propietarios no están usando AMF. Haga click para ver la lista completa."
- not_using_mfa_warning_hide: "* Los siguientes propietarios no están usando AMF. Haga click para ocultar."
+ self_no_mfa_warning_html: Considera por favor
activar
+ la autenticación de múltiples factores (AMF) para mantener tu cuenta segura.
+ not_using_mfa_warning_show: "* Algunos propietarios no están usando AMF. Haga
+ click para ver la lista completa."
+ not_using_mfa_warning_hide: "* Los siguientes propietarios no están usando AMF.
+ Haga click para ocultar."
owners_header: Propietarios
pushed_by: Subida por
using_mfa_info: "* Todos los propietarios están usando AMF."
@@ -633,7 +727,7 @@ es:
signature_period: Periodo de validez de la firma
expired: Expirado
version_navigation:
- previous_version: ← Versión anterior
+ previous_version: "← Versión anterior"
next_version: Siguiente versión →
index:
downloads: Descargas
@@ -671,7 +765,7 @@ es:
reverse_dependencies:
index:
title: Dependencias inversas para %{name}
- subtitle: "La última versión de las siguientes gemas requieren %{name}"
+ subtitle: La última versión de las siguientes gemas requieren %{name}
no_reverse_dependencies: Esta gema no tiene dependencias inversas
search:
search_reverse_dependencies_html: Buscar dependencias inversas…
@@ -699,7 +793,8 @@ es:
confirm: Confirmar
notice: Por favor confirma tu contraseña para continuar.
create:
- account_blocked: Tu cuenta ha sido bloqueada por el equipo de rubygems. Para recuperar tu cuenta envía un mensaje a support@rubygems.org, por favor.
+ account_blocked: Tu cuenta ha sido bloqueada por el equipo de rubygems. Para
+ recuperar tu cuenta envía un mensaje a support@rubygems.org, por favor.
stats:
index:
title: Estadísticas
@@ -709,7 +804,8 @@ es:
total_users: Usuarios totales
users:
create:
- email_sent: Se ha enviado un correo de confirmación a tu dirección de correo electrónico.
+ email_sent: Se ha enviado un correo de confirmación a tu dirección de correo
+ electrónico.
new:
have_account: "¿Ya tienes una cuenta?"
versions:
@@ -717,16 +813,23 @@ es:
not_hosted_notice: Esta gema no está alojada actualmente en RubyGems.org.
title: Todas las versiones de %{name}
versions_since: "%{count} versiones desde %{since}"
- imported_gem_version_notice: "Esta versión de la gema se importó a RubyGems.org el %{import_date}. La fecha que se muestra fue especificada por el autor en el archivo gemspec."
+ imported_gem_version_notice: Esta versión de la gema se importó a RubyGems.org
+ el %{import_date}. La fecha que se muestra fue especificada por el autor en
+ el archivo gemspec.
version:
yanked: borrada
adoptions:
index:
title: Adopciones
- subtitle_owner_html: Solicita nuevos responsables de mantenimiento para %{gem}
(leer más)
- subtitle_user_html: Solicita ser propietario de %{gem}
(leer más)
+ subtitle_owner_html: Solicita nuevos responsables de mantenimiento para %{gem}
+
(leer
+ más)
+ subtitle_user_html: Solicita ser propietario de %{gem}
(leer
+ más)
ownership_calls: Solicitud de propietarios
- no_ownership_calls: No hay convocatorias de propietarios para %{gem}. Los dueños de la gema no están buscando nuevos responsables de mantenimiento.
+ no_ownership_calls: No hay convocatorias de propietarios para %{gem}. Los dueños
+ de la gema no están buscando nuevos responsables de mantenimiento.
ownership_calls:
update:
success_notice: Convocatoria para propietarios de %{gem} cerrada.
@@ -734,14 +837,17 @@ es:
success_notice: Creada convocatoria para propietarios de %{gem}.
index:
title: Se buscan responsables de mantenimiento
- subtitle_html: Gemas que buscan nuevos responsables de mantenimiento
(leer más)
+ subtitle_html: Gemas que buscan nuevos responsables de mantenimiento
(leer
+ más)
share_requirements: Por favor especifica en que areas necesitas ayuda
- note_for_applicants: "Nota para candidatos:"
+ note_for_applicants: 'Nota para candidatos:'
created_by: Creado por
details: Detalles
apply: Proponte
close: Cerrar
- markup_supported_html:
Etiquetas Rdoc soportadas
+ markup_supported_html:
Etiquetas
+ Rdoc soportadas
create_call: Crear convocatoria para propietarios
ownership_requests:
create:
@@ -752,28 +858,33 @@ es:
close:
success_notice: Se han cerrado todas las candidaturas a propietario de %{gem}.
ownership_requests: Candidaturas a propietario
- note_for_owners: "Nota para propietarios:"
+ note_for_owners: 'Nota para propietarios:'
your_ownership_requests: Tus candidaturas a propietario
close_all: Cerrar todas
approve: Aprobar
gems_published: Gemas publicadas
created_at: Creado el
- no_ownership_requests: Las peticiones para unirse a tu proyecto aparecerán aquí. Todavia no hay candidaturas a propietario para %{gem}.
+ no_ownership_requests: Las peticiones para unirse a tu proyecto aparecerán aquí.
+ Todavia no hay candidaturas a propietario para %{gem}.
create_req: Crea una candidatura a propietario
- signin_to_create_html: Por favor
accede para crear una candidatura a propietario.
+ signin_to_create_html: Por favor
accede
+ para crear una candidatura a propietario.
webauthn_credentials:
callback:
success: Has dado de alta con éxito un dispositivo de seguridad.
recovery:
continue: Continuar
title: Has añadido con éxito un dispositivo de seguridad
- notice_html: 'Por favor
copia y guarda estos códigos de recuperación. Puedes utilizar estos códigos para acceder si pierdes tu dispositivo de seguridad. Cada código solo se puede usar una vez.'
+ notice_html: Por favor
copia y guarda
+ estos códigos de recuperación. Puedes utilizar estos códigos para acceder
+ si pierdes tu dispositivo de seguridad. Cada código solo se puede usar una
+ vez.
copied: "[ copiado ]"
copy: "[ copiar ]"
saved: Declaro haber guardado mis códigos de recuperación.
webauthn_credential:
- confirm_delete: "Credencial borrada"
- delete_failed: "No se pudo borrar la credencial"
+ confirm_delete: Credencial borrada
+ delete_failed: No se pudo borrar la credencial
delete: Borrar
confirm: "¿Seguro que quieres borrar esta credencial?"
saved: Dispositivo de seguridad creado con éxito
@@ -787,61 +898,110 @@ es:
api_key_roles: Roles de clave API OIDC
new_role: Crear rol de clave API
show:
- api_key_role_name: "Rol de clave API %{name}"
- automate_gh_actions_publishing: "Automatizar publicación de gemas con GitHub Actions"
- view_provider: "Ver proveedor %{issuer}"
- edit_role: "Editar rol de clave API"
- delete_role: "Borrar rol de clave API"
+ api_key_role_name: Rol de clave API %{name}
+ automate_gh_actions_publishing: Automatizar publicación de gemas con GitHub
+ Actions
+ view_provider: Ver proveedor %{issuer}
+ edit_role: Editar rol de clave API
+ delete_role: Borrar rol de clave API
confirm_delete: "¿Seguro que quieres borrar este rol?"
- deleted_at_html: "Este rol se borró hace %{time_html} y ya no puede usarse."
+ deleted_at_html: Este rol se borró hace %{time_html} y ya no puede usarse.
edit:
- edit_role: "Editar rol de clave API"
+ edit_role: Editar rol de clave API
git_hub_actions_workflow:
- title: "OIDC GitHub Actions Workflow para subir gema"
- configured_for_html: "Este rol de clave API OIDC está configurado para permitir subir %{link_html} desde GitHub Actions."
- to_automate_html: "Para automatizar lanzar %{link_html} cuando se suba una nueva etiqueta, añade el siguiente workflow a tu repositorio."
- not_github: "Este rol de clave API OIDC no está configurado para usar GitHub Actions."
- not_push: "Este rol de clave API OIDC no está configurado para permitir subir gemas."
+ title: OIDC GitHub Actions Workflow para subir gema
+ configured_for_html: Este rol de clave API OIDC está configurado para permitir
+ subir %{link_html} desde GitHub Actions.
+ to_automate_html: Para automatizar lanzar %{link_html} cuando se suba una
+ nueva etiqueta, añade el siguiente workflow a tu repositorio.
+ not_github: Este rol de clave API OIDC no está configurado para usar GitHub
+ Actions.
+ not_push: Este rol de clave API OIDC no está configurado para permitir subir
+ gemas.
copy_to_clipboard: Copiar al portapapeles
- copied: ¡Copiado!
+ copied: "¡Copiado!"
a_gem: una gema
- instructions_html: |
- Para lanzar una gema,
crea la nueva versión y genera una etiqueta nueva (usando
rake release:source_control_push
) a GitHub. El workflow empaquetará y subirá automáticamente la gema a RubyGems.org.
+ instructions_html: 'Para lanzar una gema,
crea la nueva versión y genera una etiqueta nueva (usando
+
rake release:source_control_push
) a GitHub. El workflow empaquetará
+ y subirá automáticamente la gema a RubyGems.org.
+
+ '
new:
- title: "Nuevo rol de clave API OIDC"
+ title: Nuevo rol de clave API OIDC
update:
- success: "Rol de clave API OIDC actualizado"
+ success: Rol de clave API OIDC actualizado
create:
- success: "Rol de clave API OIDC creado"
+ success: Rol de clave API OIDC creado
destroy:
- success: "Rol de clave API OIDC borrado"
+ success: Rol de clave API OIDC borrado
form:
- add_condition: "Añadir condición"
- remove_condition: "Eliminar condición!"
- add_statement: "Añadir declaración"
- remove_statement: "Eliminar declaración"
- deleted: "El rol se ha eliminado."
+ add_condition: Añadir condición
+ remove_condition: Eliminar condición!
+ add_statement: Añadir declaración
+ remove_statement: Eliminar declaración
+ deleted: El rol se ha eliminado.
providers:
index:
- title: "Proveedores de OIDC"
- description_html: "Estos son los proveedores de OIDC que están configurados para RubyGems.org.
Por favor, contacta con soporte si necesitas añadir otro proveedor OIDC."
+ title: Proveedores de OIDC
+ description_html: Estos son los proveedores de OIDC que están configurados
+ para RubyGems.org.
Por favor, contacta con soporte si necesitas añadir
+ otro proveedor OIDC.
show:
- title: "Proveedor de OIDC"
+ title: Proveedor de OIDC
id_tokens:
index:
- title: "Tokens OIDC ID"
+ title: Tokens OIDC ID
show:
- title: "Token OIDC ID"
+ title: Token OIDC ID
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other: "%{count} minutos"
- one: "1 minuto"
+ one: 1 minuto
hours:
other: "%{count} horas"
- one: "1 hora"
+ one: 1 hora
days:
other: "%{count} días"
- one: "1 día"
+ one: 1 día
seconds:
other: "%{count} segundos"
- one: "1 segundo"
+ one: 1 segundo
+ form:
+ optional:
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 118c3fe286e..812d4d95e40 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -3,6 +3,7 @@ fr:
credentials_required:
edit: Modification
failure_when_forbidden: Veuillez vérifier l'URL ou réessayer.
+ verification_expired:
feed_latest: RubyGems.org | Derniers Gems
feed_subscribed: RubyGems.org | Gems abonnés
footer_about_html: RubyGems.org est le service d’hébergement de la communauté
@@ -68,6 +69,10 @@ fr:
api_key_role:
oidc/api_key_role:
api_key_permissions:
+ oidc/trusted_publisher/github_action:
+ repository_owner_id:
+ oidc/pending_trusted_publisher:
+ rubygem_name:
errors:
messages:
unpwn:
@@ -84,6 +89,14 @@ fr:
taken:
full_name:
taken:
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken:
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable:
models:
user:
activemodel:
@@ -337,6 +350,8 @@ fr:
global_html:
gem_text:
gem_html:
+ gem_trusted_publisher_added:
+ title:
news:
show:
title: Nouvelles Versions - Toutes les Gems
@@ -601,6 +616,7 @@ fr:
code: Code Source
docs: Documentation
download: Télécharger
+ funding:
header: Liens
home: Page d'accueil
mail: Liste de diffusion
@@ -617,6 +633,7 @@ fr:
api_key_role:
name:
new:
+ trusted_publishers:
reserved:
reserved_namespace: Ce nom est réservé par rubygems.org.
dependencies:
@@ -833,6 +850,42 @@ fr:
title:
show:
title:
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other:
@@ -846,3 +899,5 @@ fr:
seconds:
other:
one:
+ form:
+ optional:
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 84fdf96ce7d..3a421f6dcb1 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -3,6 +3,7 @@ ja:
credentials_required: 認証情報が必要です
edit: 編集
failure_when_forbidden: URLを見返すか、再度送信してみてください。
+ verification_expired:
feed_latest: RubyGems.org | 最新のgemの一覧
feed_subscribed: RubyGems.org | 購読したgemの一覧
footer_about_html: RubyGems.orgはRubyコミュニティのgemのホスティングサービスです。すぐに
gemを公開して
何らかのgemがプッシュされたときに呼ばれました。
gem_text: このwebhookは以前%{gem}がプッシュされたときに呼ばれました。
gem_html: このwebhookは以前%{gem}がプッシュされたときに呼ばれました。
+ gem_trusted_publisher_added:
+ title:
news:
show:
title: 新しいリリース - 全てのgem
@@ -341,7 +356,7 @@ ja:
pages:
about:
contributors_amount: "%{count}人以上のRubyist"
- downloads_amount: '何億回ものgemダウンロード'
+ downloads_amount: 何億回ものgemダウンロード
checkout_code:
mit_licensed:
logo_header:
@@ -567,6 +582,7 @@ ja:
code: ソースコード
docs: ドキュメント
download: ダウンロード
+ funding: 寄付
header: リンク
home: ホームページ
mail: メーリングリスト
@@ -583,6 +599,7 @@ ja:
api_key_role:
name:
new:
+ trusted_publishers:
reserved:
reserved_namespace: この名前空間はrubygems.orgにより予約されています。
dependencies:
@@ -792,6 +809,42 @@ ja:
title:
show:
title:
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other:
@@ -805,3 +858,5 @@ ja:
seconds:
other:
one:
+ form:
+ optional:
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 2bedc1881ce..8f514914b4a 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -3,6 +3,7 @@ nl:
credentials_required:
edit: Wijzig
failure_when_forbidden: Controleer het webadres, en probeer het opnieuw.
+ verification_expired:
feed_latest: RubyGems.org | Nieuwste Gems
feed_subscribed: RubyGems.org | Geabonneerde Gems
footer_about_html: RubyGems.org is de gem hosting service van de Ruby community.
@@ -60,6 +61,10 @@ nl:
api_key_role:
oidc/api_key_role:
api_key_permissions:
+ oidc/trusted_publisher/github_action:
+ repository_owner_id:
+ oidc/pending_trusted_publisher:
+ rubygem_name:
errors:
messages:
unpwn:
@@ -76,6 +81,14 @@ nl:
taken:
full_name:
taken:
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken:
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable:
models:
user:
activemodel:
@@ -322,6 +335,8 @@ nl:
global_html:
gem_text:
gem_html:
+ gem_trusted_publisher_added:
+ title:
news:
show:
title:
@@ -568,6 +583,7 @@ nl:
code: Broncode
docs: Documentatie
download: Download
+ funding:
header: Links
home: Startpagina
mail: Mailing-list
@@ -584,6 +600,7 @@ nl:
api_key_role:
name:
new:
+ trusted_publishers:
reserved:
reserved_namespace:
dependencies:
@@ -787,6 +804,42 @@ nl:
title:
show:
title:
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other:
@@ -800,3 +853,5 @@ nl:
seconds:
other:
one:
+ form:
+ optional:
diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml
index b325ff3e11e..2832d81cc6d 100644
--- a/config/locales/pt-BR.yml
+++ b/config/locales/pt-BR.yml
@@ -3,6 +3,7 @@ pt-BR:
credentials_required:
edit: Editar
failure_when_forbidden: Por favor, confira a URL ou tente submetê-la novamente.
+ verification_expired:
feed_latest: RubyGems.org | Últimas Gems
feed_subscribed: RubyGems.org | Gems do seu Feed
footer_about_html: RubyGems.org é o serviço de hospedagem de gems da comunidade
@@ -67,6 +68,10 @@ pt-BR:
api_key_role:
oidc/api_key_role:
api_key_permissions:
+ oidc/trusted_publisher/github_action:
+ repository_owner_id:
+ oidc/pending_trusted_publisher:
+ rubygem_name:
errors:
messages:
unpwn: já apareceu anteriormente em um vazamento de dados e não deve ser utilizada
@@ -83,6 +88,14 @@ pt-BR:
taken:
full_name:
taken:
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken:
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable:
models:
user: Usuário
activemodel:
@@ -334,6 +347,8 @@ pt-BR:
global_html:
gem_text:
gem_html:
+ gem_trusted_publisher_added:
+ title:
news:
show:
title: Novos Releases - Todas as Gems
@@ -579,6 +594,7 @@ pt-BR:
code: Código Fonte
docs: Documentação
download: Download
+ funding:
header: Links
home: Homepage
mail: Lista de Emails
@@ -595,6 +611,7 @@ pt-BR:
api_key_role:
name:
new:
+ trusted_publishers:
reserved:
reserved_namespace: This namespace is reserved by rubygems.org.
dependencies:
@@ -810,6 +827,42 @@ pt-BR:
title:
show:
title:
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other:
@@ -823,3 +876,5 @@ pt-BR:
seconds:
other:
one:
+ form:
+ optional:
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 567acc7ad11..090afee289b 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -3,6 +3,7 @@ zh-CN:
credentials_required: 需要凭证
edit: 编辑
failure_when_forbidden: 请再次确认 URL 或尝试重新提交
+ verification_expired:
feed_latest: RubyGems.org | 最新的 Gem
feed_subscribed: RubyGems.org | 订阅的 Gem
footer_about_html: RubyGems.org 是 Ruby 社区的 Gem 托管服务。 立即 发布您的
@@ -62,6 +63,10 @@ zh-CN:
api_key_role:
oidc/api_key_role:
api_key_permissions:
+ oidc/trusted_publisher/github_action:
+ repository_owner_id:
+ oidc/pending_trusted_publisher:
+ rubygem_name:
errors:
messages:
unpwn: 曾出现过数据泄露,不应该再使用
@@ -78,6 +83,14 @@ zh-CN:
taken:
full_name:
taken:
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken:
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable:
models:
user: 用户
activemodel:
@@ -336,6 +349,8 @@ zh-CN:
gem_text: 这个 webhook 在 %{gem} 被推送时被调用。
gem_html: 这个 webhook 在 %{gem}
被推送时被调用。
+ gem_trusted_publisher_added:
+ title:
news:
show:
title: 新的发布 — 所有 Gem
@@ -575,6 +590,7 @@ zh-CN:
code: 源代码
docs: 文档
download: 下载
+ funding: 募集资金
header: 链接
home: 主页
mail: 邮件列表
@@ -591,6 +607,7 @@ zh-CN:
api_key_role:
name:
new:
+ trusted_publishers:
reserved:
reserved_namespace: 该命名空间由 RubyGems.org 保留。
dependencies:
@@ -800,6 +817,42 @@ zh-CN:
title:
show:
title:
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other:
@@ -813,3 +866,5 @@ zh-CN:
seconds:
other:
one:
+ form:
+ optional:
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index 2418a316566..526a8cb8d5b 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -3,6 +3,7 @@ zh-TW:
credentials_required:
edit: 編輯
failure_when_forbidden: 請確認 URL 或再次提交
+ verification_expired:
feed_latest: RubyGems.org | 最新 Gems
feed_subscribed: RubyGems.org | 訂閱 Gems
footer_about_html: RubyGems.org 是 Ruby 社群的 Gem 套件管理服務,讓你能立即地發佈及安裝你的 Gem 套件,並且利用
@@ -57,6 +58,10 @@ zh-TW:
api_key_role:
oidc/api_key_role:
api_key_permissions:
+ oidc/trusted_publisher/github_action:
+ repository_owner_id:
+ oidc/pending_trusted_publisher:
+ rubygem_name:
errors:
messages:
unpwn:
@@ -73,6 +78,14 @@ zh-TW:
taken:
full_name:
taken:
+ oidc/rubygem_trusted_publisher:
+ attributes:
+ rubygem:
+ taken:
+ oidc/pending_trusted_publisher:
+ attributes:
+ rubygem_name:
+ unavailable:
models:
user:
activemodel:
@@ -316,6 +329,8 @@ zh-TW:
global_html:
gem_text:
gem_html:
+ gem_trusted_publisher_added:
+ title:
news:
show:
title: 最新發佈
@@ -551,6 +566,7 @@ zh-TW:
code: 原始碼
docs: 文件
download: 下載
+ funding:
header: 相關連結
home: 首頁
mail: 郵件群組
@@ -567,6 +583,7 @@ zh-TW:
api_key_role:
name:
new:
+ trusted_publishers:
reserved:
reserved_namespace:
dependencies:
@@ -770,6 +787,42 @@ zh-TW:
title:
show:
title:
+ rubygem_trusted_publishers:
+ index:
+ title:
+ subtitle_owner_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ subtitle_owner_html:
+ pending_trusted_publishers:
+ index:
+ title:
+ valid_for_html:
+ delete:
+ create:
+ description_html:
+ destroy:
+ success:
+ create:
+ success:
+ new:
+ title:
+ trusted_publisher:
+ unsupported_type:
+ github_actions:
+ repository_owner_help_html:
+ repository_name_help_html:
+ workflow_filename_help_html:
+ environment_help_html:
+ pending:
+ rubygem_name_help_html:
duration:
minutes:
other:
@@ -783,3 +836,5 @@ zh-TW:
seconds:
other:
one:
+ form:
+ optional:
diff --git a/config/routes.rb b/config/routes.rb
index d6c2d56e9c7..5aa65e781db 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -113,6 +113,7 @@
resources :timeframe_versions, only: :index
namespace :oidc do
+ post 'trusted_publisher/exchange_token'
resources :api_key_roles, only: %i[index show], param: :token, format: 'json', defaults: { format: :json } do
member do
post :assume_role
@@ -156,6 +157,7 @@
end
resource :dashboard, only: :show, constraints: { format: /html|atom/ }
resources :profiles, only: :show
+ get "profile/me", to: "profiles#me", as: :my_profile
resource :multifactor_auth, only: %i[new create update destroy] do
get 'recovery'
post 'otp_update', to: 'multifactor_auths#otp_update', as: :otp_update
@@ -182,6 +184,7 @@
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]
+ resources :pending_trusted_publishers, except: %i[show edit update]
end
end
resources :stats, only: :index
@@ -214,6 +217,7 @@
patch 'close_all', to: 'ownership_requests#close_all', as: :close_all, on: :collection
end
resources :adoptions, only: %i[index]
+ resources :trusted_publishers, controller: 'oidc/rubygem_trusted_publishers', only: %i[index create destroy new]
end
resources :ownership_calls, only: :index
@@ -239,8 +243,10 @@
resource :session, only: %i[create destroy] do
post 'otp_create', to: 'sessions#otp_create', as: :otp_create
post 'webauthn_create', to: 'sessions#webauthn_create', as: :webauthn_create
+ post 'webauthn_full_create', to: 'sessions#webauthn_full_create', as: :webauthn_full_create
get 'verify', to: 'sessions#verify', as: :verify
post 'authenticate', to: 'sessions#authenticate', as: :authenticate
+ post 'webauthn_authenticate', to: 'sessions#webauthn_authenticate', as: :webauthn_authenticate
end
resources :users, only: %i[new create] do
diff --git a/db/migrate/20231027190405_create_oidc_trusted_publisher_github_actions.rb b/db/migrate/20231027190405_create_oidc_trusted_publisher_github_actions.rb
new file mode 100644
index 00000000000..61109730267
--- /dev/null
+++ b/db/migrate/20231027190405_create_oidc_trusted_publisher_github_actions.rb
@@ -0,0 +1,17 @@
+class CreateOIDCTrustedPublisherGitHubActions < ActiveRecord::Migration[7.0]
+ def change
+ create_table :oidc_trusted_publisher_github_actions do |t|
+ t.string :repository_owner, null: false
+ t.string :repository_name, null: false
+ t.string :repository_owner_id, null: false
+ t.string :workflow_filename, null: false
+ t.string :environment, null: true
+
+ t.timestamps
+ end
+
+ add_index :oidc_trusted_publisher_github_actions,
+ [:repository_owner, :repository_name, :repository_owner_id, :workflow_filename, :environment],
+ unique: true, name: "index_oidc_trusted_publisher_github_actions_claims"
+ end
+end
diff --git a/db/migrate/20231027191446_create_oidc_rubygem_trusted_publishers.rb b/db/migrate/20231027191446_create_oidc_rubygem_trusted_publishers.rb
new file mode 100644
index 00000000000..1a9f2506b25
--- /dev/null
+++ b/db/migrate/20231027191446_create_oidc_rubygem_trusted_publishers.rb
@@ -0,0 +1,14 @@
+class CreateOIDCRubygemTrustedPublishers < ActiveRecord::Migration[7.0]
+ def change
+ create_table :oidc_rubygem_trusted_publishers do |t|
+ t.references :rubygem, null: false, foreign_key: true
+ t.references :trusted_publisher, polymorphic: true, null: false
+
+ t.timestamps
+ end
+
+ add_index :oidc_rubygem_trusted_publishers,
+ [:rubygem_id, :trusted_publisher_id, :trusted_publisher_type],
+ unique: true, name: "index_oidc_rubygem_trusted_publishers_unique"
+ end
+end
diff --git a/db/migrate/20231102190427_add_owner_to_api_keys.rb b/db/migrate/20231102190427_add_owner_to_api_keys.rb
new file mode 100644
index 00000000000..3a47d2e7567
--- /dev/null
+++ b/db/migrate/20231102190427_add_owner_to_api_keys.rb
@@ -0,0 +1,7 @@
+class AddOwnerToApiKeys < ActiveRecord::Migration[7.0]
+ disable_ddl_transaction!
+
+ def change
+ add_reference :api_keys, :owner, polymorphic: true, null: true, index: {algorithm: :concurrently}
+ end
+end
diff --git a/db/migrate/20231108205146_create_oidc_pending_trusted_publishers.rb b/db/migrate/20231108205146_create_oidc_pending_trusted_publishers.rb
new file mode 100644
index 00000000000..903524f64e3
--- /dev/null
+++ b/db/migrate/20231108205146_create_oidc_pending_trusted_publishers.rb
@@ -0,0 +1,12 @@
+class CreateOIDCPendingTrustedPublishers < ActiveRecord::Migration[7.0]
+ def change
+ create_table :oidc_pending_trusted_publishers do |t|
+ t.string :rubygem_name
+ t.references :user, null: false, foreign_key: true
+ t.references :trusted_publisher, null: false, polymorphic: true
+ t.timestamp :expires_at, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20231120032536_change_api_key_user_id_to_null.rb b/db/migrate/20231120032536_change_api_key_user_id_to_null.rb
new file mode 100644
index 00000000000..7afcb3d87b6
--- /dev/null
+++ b/db/migrate/20231120032536_change_api_key_user_id_to_null.rb
@@ -0,0 +1,5 @@
+class ChangeApiKeyUserIdToNull < ActiveRecord::Migration[7.0]
+ def change
+ change_column_null :api_keys, :user_id, true
+ end
+end
diff --git a/db/migrate/20231120033231_change_api_key_owner_to_not_null.rb b/db/migrate/20231120033231_change_api_key_owner_to_not_null.rb
new file mode 100644
index 00000000000..a3cb7d498ca
--- /dev/null
+++ b/db/migrate/20231120033231_change_api_key_owner_to_not_null.rb
@@ -0,0 +1,6 @@
+class ChangeApiKeyOwnerToNotNull < ActiveRecord::Migration[7.0]
+ def change
+ add_check_constraint :api_keys, "owner_id IS NOT NULL", name: "api_keys_owner_id_null", validate: false
+ add_check_constraint :api_keys, "owner_type IS NOT NULL", name: "api_keys_owner_type_null", validate: false
+ end
+end
diff --git a/db/migrate/20231120033411_validate_change_api_key_owner_to_not_null.rb b/db/migrate/20231120033411_validate_change_api_key_owner_to_not_null.rb
new file mode 100644
index 00000000000..a4529a44f31
--- /dev/null
+++ b/db/migrate/20231120033411_validate_change_api_key_owner_to_not_null.rb
@@ -0,0 +1,6 @@
+class ValidateChangeApiKeyOwnerToNotNull < ActiveRecord::Migration[7.0]
+ def change
+ validate_check_constraint :api_keys, name: "api_keys_owner_id_null"
+ validate_check_constraint :api_keys, name: "api_keys_owner_type_null"
+ end
+end
diff --git a/db/migrate/20231129233528_add_version_id_to_deletions.rb b/db/migrate/20231129233528_add_version_id_to_deletions.rb
new file mode 100644
index 00000000000..72737a60de4
--- /dev/null
+++ b/db/migrate/20231129233528_add_version_id_to_deletions.rb
@@ -0,0 +1,7 @@
+class AddVersionIdToDeletions < ActiveRecord::Migration[7.0]
+ disable_ddl_transaction!
+
+ def change
+ add_reference :deletions, :version, index: {algorithm: :concurrently}
+ end
+end
diff --git a/db/migrate/20231130000000_remove_user_id_from_api_keys.rb b/db/migrate/20231130000000_remove_user_id_from_api_keys.rb
new file mode 100644
index 00000000000..37f3d30626a
--- /dev/null
+++ b/db/migrate/20231130000000_remove_user_id_from_api_keys.rb
@@ -0,0 +1,5 @@
+class RemoveUserIdFromApiKeys < ActiveRecord::Migration[7.0]
+ def change
+ safety_assured { remove_column :api_keys, :user_id, :integer }
+ end
+end
diff --git a/db/migrate/20231208004220_index_users_webauthn_id.rb b/db/migrate/20231208004220_index_users_webauthn_id.rb
new file mode 100644
index 00000000000..45a286b3070
--- /dev/null
+++ b/db/migrate/20231208004220_index_users_webauthn_id.rb
@@ -0,0 +1,7 @@
+class IndexUsersWebauthnId < ActiveRecord::Migration[7.0]
+ disable_ddl_transaction!
+
+ def change
+ add_index :users, :webauthn_id, unique: true, algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8fde4fa95c5..adb71356ef6 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_10_18_235829) do
+ActiveRecord::Schema[7.0].define(version: 2023_12_08_004220) do
# These are extensions that must be enabled in order to support this database
enable_extension "hstore"
enable_extension "pgcrypto"
@@ -37,7 +37,6 @@
end
create_table "api_keys", force: :cascade do |t|
- t.bigint "user_id", null: false
t.string "name", null: false
t.string "hashed_key", null: false
t.boolean "index_rubygems", default: false, null: false
@@ -54,8 +53,12 @@
t.datetime "soft_deleted_at"
t.string "soft_deleted_rubygem_name"
t.datetime "expires_at", precision: nil
+ t.string "owner_type"
+ t.bigint "owner_id"
t.index ["hashed_key"], name: "index_api_keys_on_hashed_key", unique: true
- t.index ["user_id"], name: "index_api_keys_on_user_id"
+ t.index ["owner_type", "owner_id"], name: "index_api_keys_on_owner"
+ t.check_constraint "owner_id IS NOT NULL", name: "api_keys_owner_id_null"
+ t.check_constraint "owner_type IS NOT NULL", name: "api_keys_owner_type_null"
end
create_table "audits", force: :cascade do |t|
@@ -78,7 +81,9 @@
t.string "platform"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
+ t.bigint "version_id"
t.index ["user_id"], name: "index_deletions_on_user_id"
+ t.index ["version_id"], name: "index_deletions_on_version_id"
end
create_table "dependencies", id: :serial, force: :cascade do |t|
@@ -277,6 +282,18 @@
t.index ["oidc_api_key_role_id"], name: "index_oidc_id_tokens_on_oidc_api_key_role_id"
end
+ create_table "oidc_pending_trusted_publishers", force: :cascade do |t|
+ t.string "rubygem_name"
+ t.bigint "user_id", null: false
+ t.string "trusted_publisher_type", null: false
+ t.bigint "trusted_publisher_id", null: false
+ t.datetime "expires_at", precision: nil, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["trusted_publisher_type", "trusted_publisher_id"], name: "index_oidc_pending_trusted_publishers_on_trusted_publisher"
+ t.index ["user_id"], name: "index_oidc_pending_trusted_publishers_on_user_id"
+ end
+
create_table "oidc_providers", force: :cascade do |t|
t.text "issuer"
t.jsonb "configuration"
@@ -286,6 +303,28 @@
t.index ["issuer"], name: "index_oidc_providers_on_issuer", unique: true
end
+ create_table "oidc_rubygem_trusted_publishers", force: :cascade do |t|
+ t.bigint "rubygem_id", null: false
+ t.string "trusted_publisher_type", null: false
+ t.bigint "trusted_publisher_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["rubygem_id", "trusted_publisher_id", "trusted_publisher_type"], name: "index_oidc_rubygem_trusted_publishers_unique", unique: true
+ t.index ["rubygem_id"], name: "index_oidc_rubygem_trusted_publishers_on_rubygem_id"
+ t.index ["trusted_publisher_type", "trusted_publisher_id"], name: "index_oidc_rubygem_trusted_publishers_on_trusted_publisher"
+ end
+
+ create_table "oidc_trusted_publisher_github_actions", force: :cascade do |t|
+ t.string "repository_owner", null: false
+ t.string "repository_name", null: false
+ t.string "repository_owner_id", null: false
+ t.string "workflow_filename", null: false
+ t.string "environment"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["repository_owner", "repository_name", "repository_owner_id", "workflow_filename", "environment"], name: "index_oidc_trusted_publisher_github_actions_claims", unique: true
+ end
+
create_table "ownership_calls", force: :cascade do |t|
t.bigint "rubygem_id"
t.bigint "user_id"
@@ -394,6 +433,7 @@
t.index ["id", "token"], name: "index_users_on_id_and_token"
t.index ["remember_token"], name: "index_users_on_remember_token"
t.index ["token"], name: "index_users_on_token"
+ t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
end
create_table "versions", id: :serial, force: :cascade do |t|
@@ -484,11 +524,12 @@
t.index ["user_id"], name: "index_webauthn_verifications_on_user_id", unique: true
end
- add_foreign_key "api_keys", "users"
add_foreign_key "oidc_api_key_roles", "oidc_providers"
add_foreign_key "oidc_api_key_roles", "users"
add_foreign_key "oidc_id_tokens", "api_keys"
add_foreign_key "oidc_id_tokens", "oidc_api_key_roles"
+ add_foreign_key "oidc_pending_trusted_publishers", "users"
+ add_foreign_key "oidc_rubygem_trusted_publishers", "rubygems"
add_foreign_key "ownerships", "users", on_delete: :cascade
add_foreign_key "versions", "api_keys", column: "pusher_api_key_id"
add_foreign_key "webauthn_credentials", "users"
diff --git a/db/seeds.rb b/db/seeds.rb
index 96df8d24be7..9424f679d1d 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -3,7 +3,8 @@
author = User.create_with(
handle: "gem-author",
password: password,
- email_confirmed: true
+ email_confirmed: true,
+ webauthn_id: "a1TLW3o1W18mTuDBfDALHhL2tZ1_E-2B03Fqsdu8Rv05V4tSsRzepe-L7Uprg356dw1tktXXcTI9TIRaK4gM-A"
).find_or_create_by!(email: "gem-author@example.com")
maintainer = User.create_with(
@@ -233,7 +234,6 @@
end
author.api_keys.find_or_create_by!(
- user: author,
hashed_key: "unexpiredmanualhashedkey",
name: "Manual",
push_rubygem: true,
@@ -260,6 +260,43 @@
last_verified_at: 10.years.since,
).find_or_create_by!(uri: "https://example.com/rubygem0/code")
+trusted_publisher = OIDC::TrustedPublisher::GitHubAction.find_or_create_by!(
+ repository_owner: "example",
+ repository_name: "rubygem0",
+ repository_owner_id: "1234567890",
+ workflow_filename: "push_gem.yml",
+ environment: nil
+)
+trusted_publisher.rubygem_trusted_publishers.find_or_create_by!(rubygem: rubygem0).trusted_publisher.api_keys.find_or_create_by!(
+ name: "GitHub Actions something",
+ hashed_key: "securehashedkey-tp",
+ push_rubygem: true,
+).pushed_versions.create_with(indexed: true).find_or_create_by!(
+ rubygem: rubygem0, number: "0.1.0", platform: "ruby", gem_platform: "ruby"
+)
+trusted_publisher.rubygem_trusted_publishers.find_or_create_by!(rubygem: rubygem1)
+
+OIDC::TrustedPublisher::GitHubAction.find_or_create_by!(
+ repository_owner: "example",
+ repository_name: "rubygem0",
+ repository_owner_id: "1234567890",
+ workflow_filename: "push_gem2.yml",
+ environment: "deploy"
+).rubygem_trusted_publishers.find_or_create_by!(rubygem: rubygem0)
+
+author.oidc_pending_trusted_publishers.create_with(
+ expires_at: 100.years.from_now
+).find_or_create_by!(
+ trusted_publisher: trusted_publisher,
+ rubygem_name: "pending-trusted-publisher-rubygem"
+)
+
+author.webauthn_credentials.create_with(nickname: "segiddins development")
+ .find_or_create_by!(
+ external_id: "QdfU3FxkjNpPqfjC4uTuNA",
+ public_key: "pQECAyYgASFYIKMIHolehDjslWQ6oOVP1-R8OR6LXEBdDfqxhjgtiiDEIlgg1RgUq_AJFT-cSMo-xP_9XxGIbBsQDEj8253QPwc8-88",
+ )
+
puts <<~MESSAGE # rubocop:disable Rails/Output
Four users were created, you can login with following combinations:
- email: #{author.email}, password: #{password} -> gem author owning few example gems
diff --git a/docker-compose.yml b/docker-compose.yml
index 9eeba79be34..1f03dd2ce09 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,7 @@
version: '3'
services:
db:
- image: postgres:11.13
+ image: postgres:12.17
ports:
- "5432:5432"
environment:
diff --git a/lib/elastic_searcher.rb b/lib/elastic_searcher.rb
index ac59b178ac4..8436e740ccb 100644
--- a/lib/elastic_searcher.rb
+++ b/lib/elastic_searcher.rb
@@ -4,8 +4,7 @@ class ElasticSearcher
Faraday::TimeoutError,
Searchkick::Error,
OpenSearch::Transport::Transport::Error,
- Errno::ECONNRESET,
- HTTPClient::KeepAliveDisconnected
+ Errno::ECONNRESET
].freeze
SearchNotAvailableError = Class.new(StandardError)
diff --git a/lib/gem_package_enumerator.rb b/lib/gem_package_enumerator.rb
index 57bcc7a70ad..3ebe6219b76 100644
--- a/lib/gem_package_enumerator.rb
+++ b/lib/gem_package_enumerator.rb
@@ -10,9 +10,9 @@ def initialize(package)
raise ArgumentError, "package must be a Gem::Package"
end
- def each(&)
- return enum_for(__method__).lazy unless block_given?
- open_data_tar { |data_tar| data_tar.each(&) }
+ def each(&blk)
+ return enum_for(__method__).lazy unless blk
+ open_data_tar { |data_tar| data_tar.each(&blk) }
end
def map(&)
@@ -25,11 +25,11 @@ def filter_map(&)
private
- def open_data_tar(&)
+ def open_data_tar(&blk) # rubocop:disable Naming/BlockForwarding
@package.verify
@package.gem.with_read_io do |io|
Gem::Package::TarReader.new(io).seek("data.tar.gz") do |gem_entry|
- @package.open_tar_gz(gem_entry, &)
+ @package.open_tar_gz(gem_entry, &blk) # rubocop:disable Naming/BlockForwarding
end
end
end
diff --git a/test/factories/api_key.rb b/test/factories/api_key.rb
index 3895329b825..6d3a35bfc5f 100644
--- a/test/factories/api_key.rb
+++ b/test/factories/api_key.rb
@@ -2,7 +2,7 @@
factory :api_key do
transient { key { "12345" } }
- user
+ association :owner, factory: :user
name { "ci-key" }
# enabled by default. disabled when show_dashboard is enabled.
diff --git a/test/factories/oidc/pending_trusted_publishers.rb b/test/factories/oidc/pending_trusted_publishers.rb
new file mode 100644
index 00000000000..02ba468adcf
--- /dev/null
+++ b/test/factories/oidc/pending_trusted_publishers.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :oidc_pending_trusted_publisher, class: "OIDC::PendingTrustedPublisher" do
+ sequence(:rubygem_name) { |n| "pending-rubygem#{n}" }
+ user
+ association :trusted_publisher, factory: :oidc_trusted_publisher_github_action
+ expires_at { 7.days.from_now }
+ end
+end
diff --git a/test/factories/oidc/rubygem_trusted_publishers.rb b/test/factories/oidc/rubygem_trusted_publishers.rb
new file mode 100644
index 00000000000..94c1f5cd751
--- /dev/null
+++ b/test/factories/oidc/rubygem_trusted_publishers.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :oidc_rubygem_trusted_publisher, class: "OIDC::RubygemTrustedPublisher" do
+ rubygem
+ association :trusted_publisher, factory: :oidc_trusted_publisher_github_action
+ end
+end
diff --git a/test/factories/oidc/trusted_publisher/github_actions.rb b/test/factories/oidc/trusted_publisher/github_actions.rb
new file mode 100644
index 00000000000..4fa51fdd885
--- /dev/null
+++ b/test/factories/oidc/trusted_publisher/github_actions.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :oidc_trusted_publisher_github_action, class: "OIDC::TrustedPublisher::GitHubAction" do
+ repository_owner { "example" }
+ sequence(:repository_name) { |n| "rubygem#{n}" }
+ repository_owner_id { "123456" }
+ workflow_filename { "push_gem.yml" }
+ environment { nil }
+ end
+end
diff --git a/test/functional/api/v1/api_keys_controller_test.rb b/test/functional/api/v1/api_keys_controller_test.rb
index 1ee90dc5b1c..e032d219515 100644
--- a/test/functional/api/v1/api_keys_controller_test.rb
+++ b/test/functional/api/v1/api_keys_controller_test.rb
@@ -158,7 +158,7 @@ def self.should_expect_otp_for_update
context "with correct OTP" do
setup do
@request.env["HTTP_OTP"] = ROTP::TOTP.new(@user.totp_seed).now
- @api_key = create(:api_key, user: @user, key: "12345", push_rubygem: true)
+ @api_key = create(:api_key, owner: @user, key: "12345", push_rubygem: true)
put :update, params: { api_key: "12345", index_rubygems: "true" }
@api_key.reload
@@ -207,6 +207,15 @@ def self.should_expect_otp_for_update
should_deny_access
end
+ context "with credentials with invalid encoding" do
+ setup do
+ @user = create(:user)
+ authorize_with("\x12\xff\x12:creds".force_encoding(Encoding::UTF_8))
+ get :show
+ end
+ should_deny_access
+ end
+
context "with correct credentials" do
setup do
@user = create(:user)
@@ -536,7 +545,7 @@ def self.should_expect_otp_for_update
context "with correct credentials" do
setup do
- @api_key = create(:api_key, user: @user, key: "12345", push_rubygem: true)
+ @api_key = create(:api_key, owner: @user, key: "12345", push_rubygem: true)
authorize_with("#{@user.email}:#{@user.password}")
end
diff --git a/test/functional/api/v1/deletions_controller_test.rb b/test/functional/api/v1/deletions_controller_test.rb
index 75a8d7908f0..6d9ca36e69f 100644
--- a/test/functional/api/v1/deletions_controller_test.rb
+++ b/test/functional/api/v1/deletions_controller_test.rb
@@ -136,7 +136,7 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase
context "with api key gem scoped" do
setup do
- @api_key = create(:api_key, name: "gem-scoped-delete-key", key: "123456", yank_rubygem: true, user: @user, rubygem_id: @rubygem.id)
+ @api_key = create(:api_key, name: "gem-scoped-delete-key", key: "123456", yank_rubygem: true, owner: @user, rubygem_id: @rubygem.id)
@request.env["HTTP_AUTHORIZATION"] = "123456"
end
@@ -397,6 +397,7 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase
should "have enqueued reindexing job" do
assert_enqueued_jobs 1, only: Indexer
assert_enqueued_jobs 1, only: UploadVersionsFileJob
+ assert_enqueued_jobs 1, only: UploadNamesFileJob
assert_enqueued_with job: UploadInfoFileJob, args: [{ rubygem_name: @rubygem.name }]
end
end
diff --git a/test/functional/api/v1/owners_controller_test.rb b/test/functional/api/v1/owners_controller_test.rb
index 5f2d14f059c..e979c4c3f53 100644
--- a/test/functional/api/v1/owners_controller_test.rb
+++ b/test/functional/api/v1/owners_controller_test.rb
@@ -107,7 +107,7 @@ def self.should_respond_to(format)
@second_user = create(:user)
@third_user = create(:user)
@ownership = create(:ownership, rubygem: @rubygem, user: @user)
- @api_key = create(:api_key, key: "12334", add_owner: true, user: @user)
+ @api_key = create(:api_key, key: "12334", add_owner: true, owner: @user)
@request.env["HTTP_AUTHORIZATION"] = "12334"
end
@@ -549,7 +549,7 @@ def self.should_respond_to(format)
@ownership = create(:ownership, rubygem: @rubygem, user: @user)
@ownership = create(:ownership, rubygem: @rubygem, user: @second_user)
- @api_key = create(:api_key, key: "12223", remove_owner: true, user: @user)
+ @api_key = create(:api_key, key: "12223", remove_owner: true, owner: @user)
@request.env["HTTP_AUTHORIZATION"] = "12223"
end
diff --git a/test/functional/api/v1/rubygems_controller_test.rb b/test/functional/api/v1/rubygems_controller_test.rb
index a165f53424b..3cc56ddf0f0 100644
--- a/test/functional/api/v1/rubygems_controller_test.rb
+++ b/test/functional/api/v1/rubygems_controller_test.rb
@@ -23,7 +23,7 @@ def self.should_respond_to_show
end
end
- def self.should_respond_to(format, &)
+ def self.should_respond_to(format, &blk) # rubocop:disable Naming/BlockForwarding
context "with #{format.to_s.upcase} for a hosted gem" do
setup do
@rubygem = create(:rubygem)
@@ -31,7 +31,7 @@ def self.should_respond_to(format, &)
get :show, params: { id: @rubygem.slug }, format: format
end
- should_respond_to_show(&)
+ should_respond_to_show(&blk) # rubocop:disable Naming/BlockForwarding
end
context "with #{format.to_s.upcase} for a hosted gem with a period in its name" do
@@ -41,7 +41,7 @@ def self.should_respond_to(format, &)
get :show, params: { id: @rubygem.slug }, format: format
end
- should_respond_to_show(&)
+ should_respond_to_show(&blk) # rubocop:disable Naming/BlockForwarding
end
end
@@ -696,7 +696,7 @@ def self.should_respond_to(format)
context "to a gem with ownership removed" do
setup do
ownership = create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test-gem123"))
- @api_key = create(:api_key, key: "12343", user: ownership.user, ownership: ownership, push_rubygem: true)
+ @api_key = create(:api_key, key: "12343", owner: ownership.user, ownership: ownership, push_rubygem: true)
ownership.destroy!
@request.env["HTTP_AUTHORIZATION"] = "12343"
@@ -713,7 +713,7 @@ def self.should_respond_to(format)
context "to a different gem" do
setup do
ownership = create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test-gem"))
- create(:api_key, key: "12343", user: ownership.user, ownership: ownership, push_rubygem: true)
+ create(:api_key, key: "12343", owner: ownership.user, ownership: ownership, push_rubygem: true)
@request.env["HTTP_AUTHORIZATION"] = "12343"
post :create, body: gem_file("test-1.0.0.gem", &:read)
@@ -729,7 +729,7 @@ def self.should_respond_to(format)
context "to the gem being pushed" do
setup do
ownership = create(:ownership, user: create(:user), rubygem: create(:rubygem, name: "test"))
- create(:api_key, key: "12343", user: ownership.user, ownership: ownership, push_rubygem: true)
+ create(:api_key, key: "12343", owner: ownership.user, ownership: ownership, push_rubygem: true)
@request.env["HTTP_AUTHORIZATION"] = "12343"
post :create, body: gem_file("test-1.0.0.gem", &:read)
diff --git a/test/functional/api/v1/webauthn_verifications_controller_test.rb b/test/functional/api/v1/webauthn_verifications_controller_test.rb
index d8af891a996..6e978ebc8c0 100644
--- a/test/functional/api/v1/webauthn_verifications_controller_test.rb
+++ b/test/functional/api/v1/webauthn_verifications_controller_test.rb
@@ -162,7 +162,7 @@ def self.should_respond_to_format(format)
context "when authenticating with an api key" do
setup do
- create(:api_key, key: "12345", push_rubygem: true, user: @user)
+ create(:api_key, key: "12345", push_rubygem: true, owner: @user)
@request.env["HTTP_AUTHORIZATION"] = "12345"
get :status, params: { webauthn_token: @user.webauthn_verification.path_token, format: :json }
end
diff --git a/test/functional/api_keys_controller_test.rb b/test/functional/api_keys_controller_test.rb
index 36df232f05a..4c1b1829402 100644
--- a/test/functional/api_keys_controller_test.rb
+++ b/test/functional/api_keys_controller_test.rb
@@ -71,7 +71,7 @@ class ApiKeysControllerTest < ActionController::TestCase
context "api key exists" do
setup do
- @api_key = create(:api_key, user: @user)
+ @api_key = create(:api_key, owner: @user)
get :index
end
@@ -162,7 +162,7 @@ class ApiKeysControllerTest < ActionController::TestCase
context "on GET to edit" do
setup do
- @api_key = create(:api_key, user: @user)
+ @api_key = create(:api_key, owner: @user)
get :edit, params: { id: @api_key.id }
end
@@ -183,7 +183,7 @@ class ApiKeysControllerTest < ActionController::TestCase
end
context "on PATCH to update" do
- setup { @api_key = create(:api_key, user: @user) }
+ setup { @api_key = create(:api_key, owner: @user) }
context "with successful save" do
setup do
@@ -251,7 +251,7 @@ class ApiKeysControllerTest < ActionController::TestCase
context "on DELETE to destroy" do
context "user is owner of key" do
- setup { @api_key = create(:api_key, user: @user) }
+ setup { @api_key = create(:api_key, owner: @user) }
context "with successful destroy" do
setup { delete :destroy, params: { id: @api_key.id } }
@@ -294,8 +294,8 @@ class ApiKeysControllerTest < ActionController::TestCase
context "on DELETE to reset" do
setup do
- create(:api_key, key: "1234", user: @user)
- create(:api_key, key: "2345", user: @user)
+ create(:api_key, key: "1234", owner: @user)
+ create(:api_key, key: "2345", owner: @user)
delete :reset
end
@@ -368,7 +368,7 @@ class ApiKeysControllerTest < ActionController::TestCase
context "on DELETE to reset" do
setup do
- create(:api_key, key: "1234", user: @user)
+ create(:api_key, key: "1234", owner: @user)
delete :reset
end
@@ -401,7 +401,7 @@ class ApiKeysControllerTest < ActionController::TestCase
context "on GET to edit" do
setup do
- @api_key = create(:api_key, user: @user)
+ @api_key = create(:api_key, owner: @user)
get :edit, params: { id: @api_key.id }
end
@@ -414,7 +414,7 @@ class ApiKeysControllerTest < ActionController::TestCase
context "on PATCH to update" do
setup do
- @api_key = create(:api_key, user: @user)
+ @api_key = create(:api_key, owner: @user)
patch :update, params: { api_key: { name: "test", add_owner: true }, id: @api_key.id }
@api_key.reload
end
@@ -424,7 +424,7 @@ class ApiKeysControllerTest < ActionController::TestCase
context "on DELETE to destroy" do
setup do
- @api_key = create(:api_key, user: @user)
+ @api_key = create(:api_key, owner: @user)
delete :destroy, params: { id: @api_key.id }
end
diff --git a/test/functional/concerns/webauthn_verifiable_test.rb b/test/functional/concerns/webauthn_verifiable_test.rb
index 2b76e596e8a..79dcf0b4f0b 100644
--- a/test/functional/concerns/webauthn_verifiable_test.rb
+++ b/test/functional/concerns/webauthn_verifiable_test.rb
@@ -78,7 +78,7 @@ class WebauthnVerifiableTest < ActionController::TestCase
setup do
get :prompt, params: { user_id: @user.id }
@challenge = session[:webauthn_authentication]["challenge"]
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
diff --git a/test/functional/email_confirmations_controller_test.rb b/test/functional/email_confirmations_controller_test.rb
index f9b75f0bf3a..d8679c4a0ff 100644
--- a/test/functional/email_confirmations_controller_test.rb
+++ b/test/functional/email_confirmations_controller_test.rb
@@ -226,7 +226,7 @@ class EmailConfirmationsControllerTest < ActionController::TestCase
@user = create(:user)
@webauthn_credential = create(:webauthn_credential, user: @user)
get :update, params: { token: @user.confirmation_token, user_id: @user.id }
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
end
diff --git a/test/functional/multifactor_auths_controller_test.rb b/test/functional/multifactor_auths_controller_test.rb
index 92a173544a8..18c0c708e90 100644
--- a/test/functional/multifactor_auths_controller_test.rb
+++ b/test/functional/multifactor_auths_controller_test.rb
@@ -408,7 +408,7 @@ class MultifactorAuthsControllerTest < ActionController::TestCase
context "on POST to webauthn_update" do
setup do
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
@@ -818,7 +818,7 @@ class MultifactorAuthsControllerTest < ActionController::TestCase
context "on POST to webauthn_update" do
setup do
- origin = "http://localhost:3000"
+ origin = WebAuthn.configuration.origin
@rp_id = URI.parse(origin).host
@client = WebAuthn::FakeClient.new(origin, encoding: false)
WebauthnHelpers.create_credential(
diff --git a/test/functional/passwords_controller_test.rb b/test/functional/passwords_controller_test.rb
index b21799d579a..e63640901b6 100644
--- a/test/functional/passwords_controller_test.rb
+++ b/test/functional/passwords_controller_test.rb
@@ -35,6 +35,14 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :success
+ should "sign in the user" do
+ assert_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
+ should "invalidate the confirmation_token" do
+ assert_nil @user.reload.confirmation_token
+ end
+
should "display edit form" do
page.assert_text("Reset password")
page.assert_selector("input[type=password][autocomplete=new-password]")
@@ -49,6 +57,10 @@ class PasswordsControllerTest < ActionController::TestCase
should redirect_to("the home page") { root_path }
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "warn about invalid url" do
assert_equal "Please double check the URL or try submitting it again.", flash[:alert]
end
@@ -62,6 +74,10 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :success
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "display otp form" do
assert page.has_content?("Multi-factor authentication")
assert page.has_content?("OTP code")
@@ -80,6 +96,10 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :success
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "display webauthn prompt" do
assert page.has_button?("Authenticate with security device")
end
@@ -97,6 +117,10 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :success
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "display webauthn prompt" do
assert page.has_button?("Authenticate with security device")
end
@@ -115,6 +139,10 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :success
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "display webauthn prompt" do
assert page.has_button?("Authenticate with security device")
end
@@ -142,9 +170,18 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :success
+ should "sign in the user" do
+ assert_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
+ should "invalidate the confirmation_token" do
+ assert_nil @user.reload.confirmation_token
+ end
+
should "display edit form" do
page.assert_text("Reset password")
end
+
should "clear mfa_expires_at" do
assert_nil @controller.session[:mfa_expires_at]
end
@@ -158,6 +195,10 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :unauthorized
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "alert about otp being incorrect" do
assert_equal "Your OTP code is incorrect.", flash[:alert]
end
@@ -194,7 +235,7 @@ class PasswordsControllerTest < ActionController::TestCase
@user = create(:user)
@webauthn_credential = create(:webauthn_credential, user: @user)
get :edit, params: { token: @user.confirmation_token, user_id: @user.id }
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
end
@@ -222,6 +263,14 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :success
+ should "sign in the user" do
+ assert_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
+ should "invalidate the confirmation_token" do
+ assert_nil @user.reload.confirmation_token
+ end
+
should "display edit form" do
page.assert_text("Reset password")
end
@@ -238,6 +287,10 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :unauthorized
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "set flash notice" do
assert_equal "Credentials required", flash[:alert]
end
@@ -266,9 +319,14 @@ class PasswordsControllerTest < ActionController::TestCase
should respond_with :unauthorized
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
+
should "set flash notice" do
assert_equal "WebAuthn::ChallengeVerificationError", flash[:alert]
end
+
should "still have the webauthn form url" do
assert_not_nil page.find(".js-webauthn-session--form")[:action]
end
@@ -318,20 +376,20 @@ class PasswordsControllerTest < ActionController::TestCase
setup do
@user = create(:user)
@api_key = @user.api_key
- @new_api_key = create(:api_key, user: @user)
+ @new_api_key = create(:api_key, owner: @user)
@old_encrypted_password = @user.encrypted_password
end
- context "with reset_api_key and invalid password" do
+ context "when not signed in" do
setup do
put :update, params: {
user_id: @user.id,
token: @user.confirmation_token,
- password_reset: { reset_api_key: "true", password: "pass" }
+ password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
}
end
- should respond_with :success
+ should redirect_to("the sign in page") { sign_in_path }
should "not change api_key" do
assert_equal(@user.reload.api_key, @api_key)
@@ -339,89 +397,145 @@ class PasswordsControllerTest < ActionController::TestCase
should "not change password" do
assert_equal(@user.reload.encrypted_password, @old_encrypted_password)
end
+ should "not sign in the user" do
+ refute_predicate @controller.request.env[:clearance], :signed_in?
+ end
end
- context "without reset_api_key and valid password" do
+ context "when signed in" do
setup do
- put :update, params: {
- user_id: @user.id,
- token: @user.confirmation_token,
- password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD }
- }
+ sign_in_as @user
+ session[:verification] = 10.minutes.from_now
+ session[:verified_user] = @user.id
end
- should respond_with :found
-
- should "not change api_key" do
- assert_equal(@user.reload.api_key, @api_key)
+ teardown do
+ session[:verification] = nil
+ session[:verified_user] = nil
end
- should "change password" do
- refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
- end
- end
- context "with reset_api_key false and valid password" do
- setup do
- put :update, params: {
- user_id: @user.id,
- token: @user.confirmation_token,
- password_reset: { reset_api_key: "false", password: PasswordHelpers::SECURE_TEST_PASSWORD }
- }
- end
+ context "with invalid password" do
+ setup do
+ put :update, params: {
+ user_id: @user.id,
+ token: @user.confirmation_token,
+ password_reset: { reset_api_key: "true", password: "pass" }
+ }
+ end
- should respond_with :found
+ should respond_with :success
- should "not change api_key" do
- assert_equal(@user.reload.api_key, @api_key)
- end
- should "change password" do
- refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+ should "not change api_key" do
+ assert_equal(@user.reload.api_key, @api_key)
+ end
+ should "not change password" do
+ assert_equal(@user.reload.encrypted_password, @old_encrypted_password)
+ end
end
- end
- context "with reset_api_key and valid password" do
- setup do
- put :update, params: {
- user_id: @user.id,
- token: @user.confirmation_token,
- password_reset: { reset_api_key: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
- }
- end
+ context "with a valid password" do
+ context "when verification has expired" do
+ setup do
+ travel 16.minutes do
+ put :update, params: {
+ user_id: @user.id,
+ token: @user.confirmation_token,
+ password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD }
+ }
+ end
+ end
- should respond_with :found
+ should set_flash[:alert]
+ should redirect_to("the verification page") { verify_session_path }
- should "change api_key" do
- refute_equal(@user.reload.api_key, @api_key)
- end
- should "change password" do
- refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
- end
- should "not delete new api key" do
- refute_predicate @new_api_key.reload, :destroyed?
- refute_empty @user.reload.api_keys
- end
- end
+ should "not sign the user out" do
+ assert_predicate @controller.request.env[:clearance], :signed_in?
+ end
+ end
- context "with reset_api_key and reset_api_keys and valid password" do
- setup do
- put :update, params: {
- user_id: @user.id,
- token: @user.confirmation_token,
- password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
- }
- end
+ context "without reset_api_key" do
+ setup do
+ put :update, params: {
+ user_id: @user.id,
+ token: @user.confirmation_token,
+ password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD }
+ }
+ end
- should respond_with :found
+ should respond_with :found
- should "change api_key" do
- refute_equal(@user.reload.api_key, @api_key)
- end
- should "change password" do
- refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
- end
- should "expire new api key" do
- assert_empty @user.reload.api_keys.unexpired
- refute_empty @user.reload.api_keys.expired
+ should "not change api_key" do
+ assert_equal(@user.reload.api_key, @api_key)
+ end
+ should "change password" do
+ refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+ end
+ end
+
+ context "with reset_api_key false" do
+ setup do
+ put :update, params: {
+ user_id: @user.id,
+ token: @user.confirmation_token,
+ password_reset: { reset_api_key: "false", password: PasswordHelpers::SECURE_TEST_PASSWORD }
+ }
+ end
+
+ should respond_with :found
+
+ should "not change api_key" do
+ assert_equal(@user.reload.api_key, @api_key)
+ end
+ should "change password" do
+ refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+ end
+ end
+
+ context "with reset_api_key" do
+ setup do
+ put :update, params: {
+ user_id: @user.id,
+ token: @user.confirmation_token,
+ password_reset: { reset_api_key: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
+ }
+ end
+
+ should respond_with :found
+
+ should "change api_key" do
+ refute_equal(@user.reload.api_key, @api_key)
+ end
+ should "change password" do
+ refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+ end
+ should "not delete new api key" do
+ refute_predicate @new_api_key.reload, :destroyed?
+ refute_empty @user.reload.api_keys
+ end
+ end
+
+ context "with reset_api_key and reset_api_keys" do
+ setup do
+ put :update, params: {
+ user_id: @user.id,
+ token: @user.confirmation_token,
+ password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
+ }
+ end
+
+ should respond_with :found
+
+ should "change api_key" do
+ refute_equal(@user.reload.api_key, @api_key)
+ end
+ should "change password" do
+ refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+ end
+ should "expire new api key" do
+ assert_empty @user.reload.api_keys.unexpired
+ refute_empty @user.reload.api_keys.expired
+ end
+ end
end
end
end
diff --git a/test/functional/profiles_controller_test.rb b/test/functional/profiles_controller_test.rb
index ce2e5eb8548..0d88590b941 100644
--- a/test/functional/profiles_controller_test.rb
+++ b/test/functional/profiles_controller_test.rb
@@ -25,6 +25,13 @@ class ProfilesControllerTest < ActionController::TestCase
end
end
+ context "on GET to me" do
+ setup { get :me }
+
+ should respond_with :redirect
+ should redirect_to("the sign in path") { sign_in_path }
+ end
+
context "on GET to show when hide email" do
setup do
@user.update(public_email: false)
@@ -75,6 +82,15 @@ class ProfilesControllerTest < ActionController::TestCase
end
end
+ context "on GET to me" do
+ setup do
+ get :me
+ end
+
+ should respond_with :redirect
+ should redirect_to("the user's profile page") { profile_path(@user.handle) }
+ end
+
context "on GET to delete" do
setup do
get :delete
diff --git a/test/functional/sessions_controller_test.rb b/test/functional/sessions_controller_test.rb
index 49805ad601e..dad172a95c2 100644
--- a/test/functional/sessions_controller_test.rb
+++ b/test/functional/sessions_controller_test.rb
@@ -776,7 +776,7 @@ def login_to_session_with_webauthn
params: { session: { who: @user.handle, password: @user.password } }
)
@challenge = session[:webauthn_authentication]["challenge"]
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb
index a0115bb99d6..c038a29023a 100644
--- a/test/functional/users_controller_test.rb
+++ b/test/functional/users_controller_test.rb
@@ -14,6 +14,17 @@ class UsersControllerTest < ActionController::TestCase
page.assert_text "Sign up"
page.assert_selector "input[type=password][autocomplete=new-password]"
end
+
+ context "when logged in" do
+ setup do
+ @user = create(:user)
+ sign_in_as(@user)
+
+ get :new
+ end
+
+ should redirect_to("root") { root_path }
+ end
end
context "on POST to create" do
@@ -26,9 +37,9 @@ class UsersControllerTest < ActionController::TestCase
end
context "when missing a parameter" do
- should "raises parameter missing" do
+ should "reports validation error" do
assert_no_changes -> { User.count } do
- post :create
+ post :create, params: { user: { password: PasswordHelpers::SECURE_TEST_PASSWORD } }
end
assert_response :ok
assert page.has_content?("Email address is not a valid email")
diff --git a/test/functional/webauthn_credentials_controller_test.rb b/test/functional/webauthn_credentials_controller_test.rb
index 4dead63a2c9..7f251296a58 100644
--- a/test/functional/webauthn_credentials_controller_test.rb
+++ b/test/functional/webauthn_credentials_controller_test.rb
@@ -89,7 +89,7 @@ class WebauthnCredentialsControllerTest < ActionController::TestCase
setup do
@nickname = SecureRandom.hex
challenge = JSON.parse(response.body)["challenge"]
- origin = "http://localhost:3000"
+ origin = WebAuthn.configuration.origin
client = WebAuthn::FakeClient.new(origin, encoding: false)
perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
@@ -148,7 +148,7 @@ class WebauthnCredentialsControllerTest < ActionController::TestCase
setup do
@nickname = ""
challenge = JSON.parse(response.body)["challenge"]
- origin = "http://localhost:3000"
+ origin = WebAuthn.configuration.origin
client = WebAuthn::FakeClient.new(origin, encoding: false)
post(
:callback,
@@ -170,7 +170,7 @@ class WebauthnCredentialsControllerTest < ActionController::TestCase
setup do
@nickname = SecureRandom.hex
challenge = SecureRandom.hex
- origin = "http://localhost:3000"
+ origin = WebAuthn.configuration.origin
client = WebAuthn::FakeClient.new(origin, encoding: false)
post(
:callback,
@@ -197,7 +197,7 @@ class WebauthnCredentialsControllerTest < ActionController::TestCase
@nickname = SecureRandom.hex
@challenge = JSON.parse(response.body)["challenge"]
- origin = "http://localhost:3000"
+ origin = WebAuthn.configuration.origin
@client = WebAuthn::FakeClient.new(origin, encoding: false)
end
diff --git a/test/functional/webauthn_verifications_controller_test.rb b/test/functional/webauthn_verifications_controller_test.rb
index cf41b7786da..49ac1361fc0 100644
--- a/test/functional/webauthn_verifications_controller_test.rb
+++ b/test/functional/webauthn_verifications_controller_test.rb
@@ -105,7 +105,7 @@ class WebauthnVerificationsControllerTest < ActionController::TestCase
context "when verifying the challenge" do
setup do
@challenge = session[:webauthn_authentication]["challenge"]
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
@@ -138,7 +138,7 @@ class WebauthnVerificationsControllerTest < ActionController::TestCase
context "when verifying the challenge with safari" do
setup do
@challenge = session[:webauthn_authentication]["challenge"]
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
@@ -197,7 +197,7 @@ class WebauthnVerificationsControllerTest < ActionController::TestCase
context "when providing wrong credentials" do
setup do
@wrong_challenge = "16b8e11ea1b46abc64aea3ecdac1c418"
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
@@ -228,7 +228,7 @@ class WebauthnVerificationsControllerTest < ActionController::TestCase
setup do
@wrong_webauthn_token = "pRpwn2mTH2D18t58"
@challenge = session[:webauthn_authentication]["challenge"]
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
@@ -252,7 +252,7 @@ class WebauthnVerificationsControllerTest < ActionController::TestCase
context "when the webauthn token has expired" do
setup do
@challenge = session[:webauthn_authentication]["challenge"]
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
@@ -277,7 +277,7 @@ class WebauthnVerificationsControllerTest < ActionController::TestCase
setup do
@challenge = session[:webauthn_authentication]["challenge"]
session[:webauthn_authentication]["port"] = nil
- @origin = "http://localhost:3000"
+ @origin = WebAuthn.configuration.origin
@rp_id = URI.parse(@origin).host
@client = WebAuthn::FakeClient.new(@origin, encoding: false)
WebauthnHelpers.create_credential(
diff --git a/test/helpers/rate_limit_helpers.rb b/test/helpers/rate_limit_helpers.rb
index 4b02e3d863b..fba31c815f2 100644
--- a/test/helpers/rate_limit_helpers.rb
+++ b/test/helpers/rate_limit_helpers.rb
@@ -73,30 +73,31 @@ def stay_under_exponential_limit(scope)
Rack::Attack::EXP_BACKOFF_LEVELS.each do |level|
under_backoff_limit = (Rack::Attack::EXP_BASE_REQUEST_LIMIT * level) - 1
throttle_level_key = "#{scope}/#{level}:#{@ip_address}"
- under_backoff_limit.times { Rack::Attack.cache.count(throttle_level_key, exp_base_limit_period**level) }
+ update_limit_for(throttle_level_key, under_backoff_limit, exp_base_limit_period**level)
end
end
def update_limit_for(key, limit, period = limit_period)
+ key = Rack::Attack.throttle_discriminator_normalizer.call(key)
limit.times { Rack::Attack.cache.count(key, period) }
end
def exceed_exponential_limit_for(scope, level)
expo_exceeding_limit = exceeding_exp_base_limit * level
expo_limit_period = exp_base_limit_period**level
- expo_exceeding_limit.times { Rack::Attack.cache.count("#{scope}:#{@ip_address}", expo_limit_period) }
+ update_limit_for("#{scope}:#{@ip_address}", expo_exceeding_limit, expo_limit_period)
end
def exceed_exponential_user_limit_for(scope, id, level)
expo_exceeding_limit = exceeding_exp_base_limit * level
expo_limit_period = exp_base_limit_period**level
- expo_exceeding_limit.times { Rack::Attack.cache.count("#{scope}:#{id}", expo_limit_period) }
+ update_limit_for("#{scope}:#{id}", expo_exceeding_limit, expo_limit_period)
end
def exceed_exponential_api_key_limit_for(scope, user_display_id, level)
expo_exceeding_limit = exceeding_exp_base_limit * level
expo_limit_period = exp_base_limit_period**level
- expo_exceeding_limit.times { Rack::Attack.cache.count("#{scope}:#{user_display_id}", expo_limit_period) }
+ update_limit_for("#{scope}:#{user_display_id}", expo_exceeding_limit, expo_limit_period)
end
def encode(username, password)
diff --git a/test/integration/api/compact_index_test.rb b/test/integration/api/compact_index_test.rb
index 16f16fda6f5..cc86fe99874 100644
--- a/test/integration/api/compact_index_test.rb
+++ b/test/integration/api/compact_index_test.rb
@@ -3,11 +3,11 @@
class CompactIndexTest < ActionDispatch::IntegrationTest
def etag(body)
- '"' << Digest::MD5.hexdigest(body) << '"'
+ %("#{Digest::MD5.hexdigest(body)}")
end
def digest(body)
- "sha-256=#{Base64.encode64(Digest::SHA256.digest(body)).strip}"
+ Digest::SHA256.base64digest(body)
end
setup do
@@ -62,10 +62,12 @@ def digest(body)
assert_response :success
expected_body = "---\ngemA\ngemA1\ngemA2\ngemB\n"
+ expected_digest = digest(expected_body)
assert_equal expected_body, @response.body
assert_equal etag(expected_body), @response.headers["ETag"]
- assert_equal digest(expected_body), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
assert_equal %w[gemA gemA1 gemA2 gemB], Rails.cache.read("names")
end
@@ -74,9 +76,11 @@ def digest(body)
assert_response 206
full_body = "---\ngemA\ngemA1\ngemA2\ngemB\n"
+ expected_digest = digest(full_body)
assert_equal etag(full_body), @response.headers["ETag"]
- assert_equal digest(full_body), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
assert_equal "gemA2\ngemB\n", @response.body
end
@@ -87,25 +91,29 @@ def digest(body)
gem_b_match = "gemB 1.0.0 qw2dwe\n"
get versions_path
+ expected_digest = digest(@response.body)
assert_response :success
assert_match file_contents, @response.body
assert_match(/#{gem_b_match}#{gem_a_match}/, @response.body)
assert_equal etag(@response.body), @response.headers["ETag"]
- assert_equal digest(@response.body), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
end
test "/versions partial response" do
get versions_path
full_response_body = @response.body
partial_body = "1.0.0 013we2\ngemA 2.0.0 1cf94r\ngemA 1.2.0 13q4es\ngemA 2.1.0 e217fz\n"
+ expected_digest = digest(full_response_body)
get versions_path, env: { range: "bytes=229-" }
assert_response 206
assert_equal partial_body, @response.body
assert_equal etag(full_response_body), @response.headers["ETag"]
- assert_equal digest(full_response_body), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
end
test "/versions updates on gem yank" do
@@ -121,10 +129,13 @@ def digest(body)
get versions_path
full_response_body = @response.body
+ expected_digest = digest(full_response_body)
+
get versions_path, env: { range: "bytes=206-" }
assert_equal etag(full_response_body), @response.headers["ETag"]
- assert_equal digest(full_response_body), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
assert_equal expected, @response.body
end
@@ -143,13 +154,15 @@ def digest(body)
1.2.0 |checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>1.9
2.1.0 gemA1:= 1.0.0,gemA2:= 1.0.0|checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>=2.0
VERSIONS_FILE
+ expected_digest = digest(expected)
get info_path(gem_name: "gemA")
assert_response :success
assert_equal expected, @response.body
assert_equal etag(expected), @response.headers["ETag"]
- assert_equal digest(expected), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
assert_equal expected, CompactIndex.info(Rails.cache.read("info/gemA"))
end
@@ -182,13 +195,15 @@ def digest(body)
---
1.0.0 |checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>= 2.6.3
VERSIONS_FILE
+ expected_digest = digest(expected)
get info_path(gem_name: "gemC")
assert_response :success
assert_equal(expected, @response.body)
assert_equal etag(expected), @response.headers["ETag"]
- assert_equal digest(expected), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
end
test "/info with nonexistent gem" do
@@ -217,12 +232,14 @@ def digest(body)
---
1.0.0 aaab:>= 0,aaab:~> 0.2,bbcc:= 1.0.0|checksum:b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78,ruby:>= 2.0.0,rubygems:>= 2.6.3
VERSIONS_FILE
+ expected_digest = digest(expected)
get info_path(gem_name: "gemB")
assert_response :success
assert_equal(expected, @response.body)
assert_equal etag(expected), @response.headers["ETag"]
- assert_equal digest(expected), @response.headers["Digest"]
+ assert_equal "sha-256=#{expected_digest}", @response.headers["Digest"]
+ assert_equal "sha-256=:#{expected_digest}:", @response.headers["Repr-Digest"]
end
end
diff --git a/test/integration/api/v1/github_secret_scanning_test.rb b/test/integration/api/v1/github_secret_scanning_test.rb
index 71cab1abb74..5a69478a63a 100644
--- a/test/integration/api/v1/github_secret_scanning_test.rb
+++ b/test/integration/api/v1/github_secret_scanning_test.rb
@@ -130,7 +130,7 @@ class Api::V1::GitHubSecretScanningTest < ActionDispatch::IntegrationTest
assert_equal "true_positive", json.last["label"]
assert_equal @tokens.last["token"], json.last["token_raw"]
- assert_raises(ActiveRecord::RecordNotFound) { @api_key.reload }
+ assert_predicate @api_key.reload, :expired?
end
should "delivers an email" do
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 32db380c24f..1acdfd1895e 100644
--- a/test/integration/api/v1/oidc/api_key_roles_test.rb
+++ b/test/integration/api/v1/oidc/api_key_roles_test.rb
@@ -8,7 +8,7 @@ class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest
@role = create(:oidc_api_key_role)
@user = @role.user
@user_api_key = "12323"
- @api_key = create(:api_key, user: @user, key: @user_api_key)
+ @api_key = create(:api_key, owner: @user, key: @user_api_key)
end
should "return the user's roles" do
@@ -47,7 +47,7 @@ class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest
@role = create(:oidc_api_key_role)
@user = @role.user
@user_api_key = "12323"
- @api_key = create(:api_key, user: @user, key: @user_api_key)
+ @api_key = create(:api_key, owner: @user, key: @user_api_key)
end
should "return the user's roles" do
@@ -107,7 +107,7 @@ def jwt(claims = @claims, key: @pkey)
"base_ref" => "",
"head_ref" => "",
"ref_type" => "branch",
- "workflow" => ".github/workflows/token.yml",
+ "workflow" => "token",
"event_name" => "push",
"repository" => "segiddins/oidc-test",
"run_number" => "4",
diff --git a/test/integration/api/v1/oidc/id_tokens_test.rb b/test/integration/api/v1/oidc/id_tokens_test.rb
index ecfab025740..60bd7154cc4 100644
--- a/test/integration/api/v1/oidc/id_tokens_test.rb
+++ b/test/integration/api/v1/oidc/id_tokens_test.rb
@@ -9,7 +9,7 @@ class Api::V1::OIDC::IdTokensTest < ActionDispatch::IntegrationTest
@id_token = create(:oidc_id_token, user: @user, api_key_role: @role)
@user_api_key = "12323"
- @api_key = create(:api_key, user: @user, key: @user_api_key)
+ @api_key = create(:api_key, owner: @user, key: @user_api_key)
end
context "on GET to index" do
diff --git a/test/integration/api/v1/oidc/providers_test.rb b/test/integration/api/v1/oidc/providers_test.rb
index 9d85bbbcacb..41c9eac6540 100644
--- a/test/integration/api/v1/oidc/providers_test.rb
+++ b/test/integration/api/v1/oidc/providers_test.rb
@@ -8,7 +8,7 @@ class Api::V1::OIDC::ProvidersTest < ActionDispatch::IntegrationTest
@user = create(:user)
@user_api_key = "12323"
- @api_key = create(:api_key, user: @user, key: @user_api_key)
+ @api_key = create(:api_key, owner: @user, key: @user_api_key)
end
context "on GET to index" do
diff --git a/test/integration/api/v1/oidc/trusted_publisher_controller_test.rb b/test/integration/api/v1/oidc/trusted_publisher_controller_test.rb
new file mode 100644
index 00000000000..69aa29a89da
--- /dev/null
+++ b/test/integration/api/v1/oidc/trusted_publisher_controller_test.rb
@@ -0,0 +1,208 @@
+require "test_helper"
+
+class Api::V1::OIDC::TrustedPublisherControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @pkey = OpenSSL::PKey::RSA.generate(2048)
+ create(:oidc_provider, issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER, pkey: @pkey)
+
+ @claims = {
+ "aud" => Gemcutter::HOST,
+ "exp" => 1_680_020_837,
+ "iat" => 1_680_020_537,
+ "iss" => "https://token.actions.githubusercontent.com",
+ "jti" => "79685b65-945d-450a-a3d8-a36bcf72c23d",
+ "nbf" => 1_680_019_937,
+ "ref" => "refs/heads/main",
+ "sha" => "04de3558bc5861874a86f8fcd67e516554101e71",
+ "sub" => "repo:segiddins/oidc-test:ref:refs/heads/main",
+ "actor" => "segiddins",
+ "run_id" => "4545231084",
+ "actor_id" => "1946610",
+ "base_ref" => "",
+ "head_ref" => "",
+ "ref_type" => "branch",
+ "workflow" => "token",
+ "event_name" => "push",
+ "repository" => "segiddins/oidc-test",
+ "run_number" => "4",
+ "run_attempt" => "1",
+ "workflow_ref" => "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main",
+ "workflow_sha" => "04de3558bc5861874a86f8fcd67e516554101e71",
+ "repository_id" => "620393838",
+ "job_workflow_ref" => "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/main",
+ "job_workflow_sha" => "04de3558bc5861874a86f8fcd67e516554101e71",
+ "repository_owner" => "segiddins",
+ "runner_environment" => "github-hosted",
+ "repository_owner_id" => "1946610",
+ "repository_visibility" => "public"
+ }
+
+ travel_to Time.zone.at(1_680_020_830) # after the JWT iat, before the exp
+ end
+
+ def jwt(claims = @claims, key: @pkey)
+ JSON::JWT.new(claims).sign(key.to_jwk)
+ end
+
+ context "POST exchange_token" do
+ should "return not found with no matching trusted publisher" do
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "return not found when owner has changed" do
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "123",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "return not found with an unknown issuer" do
+ @claims["iss"] = "https://unknown.example.com"
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "1946610",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "return not found with an unsupported issuer" do
+ @claims["iss"] = "https://unknown.example.com"
+ create(:oidc_provider, issuer: @claims["iss"], pkey: @pkey)
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "1946610",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "return bad request with an invalid JWT" do
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: "invalid" }
+
+ assert_response :bad_request
+ end
+
+ should "return bad request with invalid JSON" do
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: "a.a.a" }
+
+ assert_response :bad_request
+ end
+
+ should "return not found when time is before nbf" do
+ @claims["nbf"] += 1_000_000
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "1946610",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "return not found when time is after exp" do
+ @claims["exp"] -= 1_000_000
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "1946610",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "return not found when signature validation fails" do
+ @claims["exp"] -= 1_000_000
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "1946610",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt(key: OpenSSL::PKey::RSA.generate(2048)).to_s }
+
+ assert_response :not_found
+ end
+
+ should "return not found when workflow is from a different ref" do
+ @claims["job_workflow_ref"] = "segiddins/oidc-test/.github/workflows/token.yml@refs/heads/other"
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "1946610",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "return not found when audience is wrong" do
+ @claims["aud"] = "other.com"
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "123",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :not_found
+ end
+
+ should "succeed with matching trusted publisher" do
+ trusted_publisher = build(:oidc_trusted_publisher_github_action,
+ repository_name: "oidc-test",
+ repository_owner_id: "1946610",
+ workflow_filename: "token.yml")
+ trusted_publisher.repository_owner = "segiddins"
+ trusted_publisher.save!
+ post api_v1_oidc_trusted_publisher_exchange_token_path,
+ params: { jwt: jwt.to_s }
+
+ assert_response :success
+
+ resp = response.parsed_body
+
+ assert_match(/^rubygems_/, resp["rubygems_api_key"])
+ assert_equal({
+ "rubygems_api_key" => resp["rubygems_api_key"],
+ "name" => "GitHub Actions segiddins/oidc-test @ .github/workflows/token.yml 2023-03-28T16:22:17Z",
+ "scopes" => ["push_rubygem"],
+ "expires_at" => 15.minutes.from_now
+ }, resp)
+
+ api_key = trusted_publisher.api_keys.sole
+
+ assert_equal api_key.owner, trusted_publisher
+ end
+ end
+end
diff --git a/test/integration/api/v1/owner_test.rb b/test/integration/api/v1/owner_test.rb
index 550fbb613a4..ef95c7e8574 100644
--- a/test/integration/api/v1/owner_test.rb
+++ b/test/integration/api/v1/owner_test.rb
@@ -9,6 +9,10 @@ class Api::V1::OwnerTest < ActionDispatch::IntegrationTest
@other_user = create(:api_key, key: @other_user_api_key, add_owner: true, remove_owner: true).user
post session_path(session: { who: @user.handle, password: PasswordHelpers::SECURE_TEST_PASSWORD })
+ @trusted_publisher_api_key = "12325"
+ @trusted_publisher = create(:oidc_trusted_publisher_github_action)
+ create(:api_key, key: @trusted_publisher_api_key, owner: @trusted_publisher)
+
@rubygem = create(:rubygem, number: "1.0.0")
create(:ownership, user: @user, rubygem: @rubygem)
end
@@ -66,5 +70,17 @@ class Api::V1::OwnerTest < ActionDispatch::IntegrationTest
headers: { "HTTP_AUTHORIZATION" => @other_user_api_key }
assert_response :unauthorized
+
+ post api_v1_rubygem_owners_path(@rubygem.slug),
+ params: { email: @other_user.email },
+ headers: { "HTTP_AUTHORIZATION" => @trusted_publisher_api_key }
+
+ assert_response :forbidden
+
+ delete api_v1_rubygem_owners_path(@rubygem.slug),
+ params: { email: @other_user.email },
+ headers: { "HTTP_AUTHORIZATION" => @trusted_publisher_api_key }
+
+ assert_response :forbidden
end
end
diff --git a/test/integration/api/v1/rubygems_test.rb b/test/integration/api/v1/rubygems_test.rb
index b7761e55023..78ab0650b38 100644
--- a/test/integration/api/v1/rubygems_test.rb
+++ b/test/integration/api/v1/rubygems_test.rb
@@ -4,12 +4,12 @@ class Api::V1::RubygemsTest < ActionDispatch::IntegrationTest
setup do
@key = "12345"
@user = create(:user)
- create(:api_key, user: @user, key: @key, index_rubygems: true, push_rubygem: true)
+ create(:api_key, owner: @user, key: @key, index_rubygems: true, push_rubygem: true)
end
test "request has remote addr present" do
ip_address = "1.2.3.4"
- RackAttackReset.expects(:gem_push_backoff).with(ip_address, @user.display_id).once
+ RackAttackReset.expects(:gem_push_backoff).with(ip_address, @user.to_gid).once
post "/api/v1/gems",
params: gem_file("test-1.0.0.gem", &:read),
diff --git a/test/integration/avo/oidc_pending_trusted_publishers_controller_test.rb b/test/integration/avo/oidc_pending_trusted_publishers_controller_test.rb
new file mode 100644
index 00000000000..46193b3da45
--- /dev/null
+++ b/test/integration/avo/oidc_pending_trusted_publishers_controller_test.rb
@@ -0,0 +1,27 @@
+require "test_helper"
+
+class Avo::OIDCPendingTrustedPublishersControllerTest < ActionDispatch::IntegrationTest
+ include AdminHelpers
+
+ test "getting pending trusted publishers as admin" do
+ admin_sign_in_as create(:admin_github_user, :is_admin)
+
+ get avo.resources_oidc_pending_trusted_publishers_path
+
+ assert_response :success
+
+ oidc_pending_trusted_publisher = create(:oidc_pending_trusted_publisher)
+
+ get avo.resources_oidc_pending_trusted_publishers_path
+
+ assert_response :success
+ page.assert_text oidc_pending_trusted_publisher.rubygem_name
+ page.assert_text oidc_pending_trusted_publisher.trusted_publisher.name
+
+ get avo.resources_oidc_pending_trusted_publisher_path(oidc_pending_trusted_publisher)
+
+ assert_response :success
+ page.assert_text oidc_pending_trusted_publisher.rubygem_name
+ page.assert_text oidc_pending_trusted_publisher.trusted_publisher.name
+ end
+end
diff --git a/test/integration/avo/oidc_rubygem_trusted_publishers_controller_test.rb b/test/integration/avo/oidc_rubygem_trusted_publishers_controller_test.rb
new file mode 100644
index 00000000000..7a9a643cd03
--- /dev/null
+++ b/test/integration/avo/oidc_rubygem_trusted_publishers_controller_test.rb
@@ -0,0 +1,27 @@
+require "test_helper"
+
+class Avo::OIDCRubygemTrustedPublishersControllerTest < ActionDispatch::IntegrationTest
+ include AdminHelpers
+
+ test "getting rubygem trusted publishers as admin" do
+ admin_sign_in_as create(:admin_github_user, :is_admin)
+
+ get avo.resources_oidc_rubygem_trusted_publishers_path
+
+ assert_response :success
+
+ oidc_rubygem_trusted_publisher = create(:oidc_rubygem_trusted_publisher)
+
+ get avo.resources_oidc_rubygem_trusted_publishers_path
+
+ assert_response :success
+ page.assert_text oidc_rubygem_trusted_publisher.rubygem.name
+ page.assert_text oidc_rubygem_trusted_publisher.trusted_publisher.name
+
+ get avo.resources_oidc_rubygem_trusted_publisher_path(oidc_rubygem_trusted_publisher)
+
+ assert_response :success
+ page.assert_text oidc_rubygem_trusted_publisher.rubygem.name
+ page.assert_text oidc_rubygem_trusted_publisher.trusted_publisher.name
+ end
+end
diff --git a/test/integration/avo/oidc_trusted_publisher_github_actions_controller_test.rb b/test/integration/avo/oidc_trusted_publisher_github_actions_controller_test.rb
new file mode 100644
index 00000000000..981aa0c9782
--- /dev/null
+++ b/test/integration/avo/oidc_trusted_publisher_github_actions_controller_test.rb
@@ -0,0 +1,25 @@
+require "test_helper"
+
+class Avo::OIDCTrustedPublisherGitHubActionsControllerTest < ActionDispatch::IntegrationTest
+ include AdminHelpers
+
+ test "getting github actions trusted publishers as admin" do
+ admin_sign_in_as create(:admin_github_user, :is_admin)
+
+ get avo.resources_oidc_trusted_publisher_github_actions_path
+
+ assert_response :success
+
+ oidc_trusted_publisher_github_action = create(:oidc_trusted_publisher_github_action)
+
+ get avo.resources_oidc_trusted_publisher_github_actions_path
+
+ assert_response :success
+ page.assert_text oidc_trusted_publisher_github_action.repository_owner
+
+ get avo.resources_oidc_trusted_publisher_github_action_path(oidc_trusted_publisher_github_action)
+
+ assert_response :success
+ page.assert_text oidc_trusted_publisher_github_action.repository_owner
+ end
+end
diff --git a/test/integration/avo/webauthn_credentials_controller_test.rb b/test/integration/avo/webauthn_credentials_controller_test.rb
new file mode 100644
index 00000000000..f79ed681ebd
--- /dev/null
+++ b/test/integration/avo/webauthn_credentials_controller_test.rb
@@ -0,0 +1,16 @@
+require "test_helper"
+
+class Avo::WebauthnCredentialsControllerTest < ActionDispatch::IntegrationTest
+ include AdminHelpers
+
+ test "getting webauthn credentials as admin" do
+ admin_sign_in_as create(:admin_github_user, :is_admin)
+
+ webauthn_credential = create(:webauthn_credential)
+
+ get avo.resources_webauthn_credential_path(webauthn_credential)
+
+ assert_response :success
+ page.assert_text webauthn_credential.external_id
+ end
+end
diff --git a/test/integration/avo/webauthn_verifications_controller_test.rb b/test/integration/avo/webauthn_verifications_controller_test.rb
new file mode 100644
index 00000000000..f0111e28288
--- /dev/null
+++ b/test/integration/avo/webauthn_verifications_controller_test.rb
@@ -0,0 +1,16 @@
+require "test_helper"
+
+class Avo::WebauthnVerificationsControllerTest < ActionDispatch::IntegrationTest
+ include AdminHelpers
+
+ test "getting webauthn verifications as admin" do
+ admin_sign_in_as create(:admin_github_user, :is_admin)
+
+ webauthn_verification = create(:webauthn_verification)
+
+ get avo.resources_webauthn_verification_path(webauthn_verification)
+
+ assert_response :success
+ page.assert_text webauthn_verification.path_token
+ end
+end
diff --git a/test/integration/dashboard_test.rb b/test/integration/dashboard_test.rb
index 3ac4f7c7d82..3570b196f1a 100644
--- a/test/integration/dashboard_test.rb
+++ b/test/integration/dashboard_test.rb
@@ -10,7 +10,7 @@ class DashboardTest < ActionDispatch::IntegrationTest
test "request with array of api keys does not pass autorization" do
delete sign_out_path
- create(:api_key, user: @user, key: "1234", show_dashboard: true)
+ create(:api_key, owner: @user, key: "1234", show_dashboard: true)
rubygem = create(:rubygem, name: "sandworm", number: "1.0.0")
create(:subscription, rubygem: rubygem, user: @user)
diff --git a/test/integration/email_confirmation_test.rb b/test/integration/email_confirmation_test.rb
index 0c03d672e81..275ea33e32d 100644
--- a/test/integration/email_confirmation_test.rb
+++ b/test/integration/email_confirmation_test.rb
@@ -91,8 +91,6 @@ def request_confirmation_mail(email)
assert page.has_content? "Multi-factor authentication"
assert page.has_content? "Security Device"
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
-
click_on "Authenticate with security device"
find(:css, ".header__popup-link").click
diff --git a/test/integration/oidc/pending_trusted_publishers_controller_test.rb b/test/integration/oidc/pending_trusted_publishers_controller_test.rb
new file mode 100644
index 00000000000..0f11ef9e26d
--- /dev/null
+++ b/test/integration/oidc/pending_trusted_publishers_controller_test.rb
@@ -0,0 +1,152 @@
+require "test_helper"
+
+class OIDC::PendingTrustedPublishersControllerTest < 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 })
+
+ @trusted_publisher = create(:oidc_pending_trusted_publisher, 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 index" do
+ get profile_oidc_pending_trusted_publishers_url
+
+ assert_response :success
+ end
+
+ should "get new" do
+ get new_profile_oidc_pending_trusted_publisher_url
+
+ assert_response :success
+ end
+
+ should "create trusted publisher" do
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ assert_difference("OIDC::PendingTrustedPublisher.count") do
+ trusted_publisher = build(:oidc_pending_trusted_publisher)
+ post profile_oidc_pending_trusted_publishers_url, params: {
+ oidc_pending_trusted_publisher: {
+ rubygem_name: trusted_publisher.rubygem_name,
+ trusted_publisher_type: trusted_publisher.trusted_publisher_type,
+ trusted_publisher_attributes: trusted_publisher.trusted_publisher.as_json
+ }
+ }
+ end
+
+ assert_redirected_to profile_oidc_pending_trusted_publishers_url
+ end
+
+ should "error creating trusted publisher with type" do
+ assert_no_difference("OIDC::PendingTrustedPublisher.count") do
+ post profile_oidc_pending_trusted_publishers_url, params: {
+ oidc_pending_trusted_publisher: {
+ rubygem_name: "rubygem1",
+ trusted_publisher_type: "Hash",
+ trusted_publisher_attributes: { repository_owner: "example" }
+ }
+ }
+
+ assert_response :redirect
+ assert_equal "Unsupported trusted publisher type", flash[:error]
+ end
+ end
+
+ should "error creating trusted publisher with unknown repository owner" do
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 404, body: { message: "Not Found" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ assert_no_difference("OIDC::PendingTrustedPublisher.count") do
+ post profile_oidc_pending_trusted_publishers_url, params: {
+ oidc_pending_trusted_publisher: {
+ rubygem_name: "rubygem1",
+ trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name,
+ trusted_publisher_attributes: { repository_owner: "example" }
+ }
+ }
+
+ assert_response :unprocessable_entity
+ assert_equal [
+ "Trusted publisher repository name can't be blank",
+ "Trusted publisher workflow filename can't be blank",
+ "Trusted publisher repository owner can't be blank"
+ ].to_sentence, flash[:error]
+ end
+ end
+
+ should "error creating invalid trusted publisher" do
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ assert_no_difference("OIDC::PendingTrustedPublisher.count") do
+ post profile_oidc_pending_trusted_publishers_url, params: {
+ oidc_pending_trusted_publisher: {
+ rubygem_name: "rubygem1",
+ trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name,
+ trusted_publisher_attributes: { repository_name: "rubygem1", repository_owner: "example", workflow_filename: "ci.NO" }
+ }
+ }
+
+ assert_response :unprocessable_entity
+ assert_equal ["Trusted publisher workflow filename must end with .yml or .yaml"].to_sentence, flash[:error]
+ end
+ end
+
+ should "destroy trusted publisher" do
+ assert_difference("OIDC::PendingTrustedPublisher.count", -1) do
+ delete profile_oidc_pending_trusted_publisher_url(@trusted_publisher)
+ end
+
+ assert_redirected_to profile_oidc_pending_trusted_publishers_url
+
+ assert_raises ActiveRecord::RecordNotFound do
+ @trusted_publisher.reload
+ end
+ end
+
+ should "return not found on destroy for other users trusted publisher" do
+ trusted_publisher = create(:oidc_pending_trusted_publisher)
+ assert_no_difference("OIDC::PendingTrustedPublisher.count") do
+ delete profile_oidc_pending_trusted_publisher_url(trusted_publisher)
+
+ assert_response :not_found
+ end
+ end
+ end
+
+ context "without a verified session" do
+ should "redirect index to verify" do
+ get profile_oidc_pending_trusted_publishers_url
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect new to verify" do
+ get new_profile_oidc_pending_trusted_publisher_url
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect create to verify" do
+ post profile_oidc_pending_trusted_publishers_url
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect destroy to verify" do
+ delete new_profile_oidc_pending_trusted_publisher_url
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+ end
+end
diff --git a/test/integration/oidc/rubygem_trusted_publishers_controller_test.rb b/test/integration/oidc/rubygem_trusted_publishers_controller_test.rb
new file mode 100644
index 00000000000..4f8ab977973
--- /dev/null
+++ b/test/integration/oidc/rubygem_trusted_publishers_controller_test.rb
@@ -0,0 +1,204 @@
+require "test_helper"
+
+class OIDC::RubygemTrustedPublishersControllerTest < 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 })
+
+ @rubygem = create(:rubygem, owners: [@user])
+ create(:version, rubygem: @rubygem)
+ @trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem)
+ end
+
+ context "with a verified session" do
+ setup do
+ post(authenticate_session_path(verify_password: { password: PasswordHelpers::SECURE_TEST_PASSWORD }))
+ end
+
+ should "respond forbidden for non-owner" do
+ @rubygem.disown
+
+ get rubygem_trusted_publishers_url(@rubygem.slug)
+
+ assert_response :forbidden
+ end
+
+ should "get index" do
+ create(:oidc_rubygem_trusted_publisher, rubygem: @rubygem,
+ trusted_publisher: create(:oidc_trusted_publisher_github_action, environment: "production"))
+ get rubygem_trusted_publishers_url(@rubygem.slug)
+
+ assert_response :success
+ end
+
+ should "get new" do
+ get new_rubygem_trusted_publisher_url(@rubygem.slug)
+
+ assert_response :success
+ end
+
+ should "get new for a github rubygem" do
+ stub_request(:get, "https://api.github.com/repos/example/rubygem1/contents/.github/workflows")
+ .to_return(status: 200, body: [
+ { name: "ci.yml", type: "file" },
+ { name: "push_rubygem.yml", type: "file" },
+ { name: "push_README.md", type: "file" },
+ { name: "push.yml", type: "directory" }
+ ].to_json, headers: { "Content-Type" => "application/json" })
+
+ create(:version, rubygem: @rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem1" })
+
+ get new_rubygem_trusted_publisher_url(@rubygem.slug)
+
+ assert_response :success
+
+ page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_owner]'][value='example']")
+ page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_name]'][value='rubygem1']")
+ page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][workflow_filename]'][value='push_rubygem.yml']")
+ end
+
+ should "get new for a github rubygem with no found workflows" do
+ stub_request(:get, "https://api.github.com/repos/example/rubygem1/contents/.github/workflows")
+ .to_return(status: 404, body: { message: "Not Found" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ create(:version, rubygem: @rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem1" })
+
+ get new_rubygem_trusted_publisher_url(@rubygem.slug)
+
+ assert_response :success
+
+ page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_owner]'][value='example']")
+ page.assert_selector("input[name='oidc_rubygem_trusted_publisher[trusted_publisher_attributes][repository_name]'][value='rubygem1']")
+ end
+
+ should "create trusted publisher" do
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ assert_difference("OIDC::RubygemTrustedPublisher.count") do
+ trusted_publisher = build(:oidc_rubygem_trusted_publisher, rubygem: @rubygem)
+ post rubygem_trusted_publishers_url(@rubygem.slug), params: {
+ oidc_rubygem_trusted_publisher: {
+ trusted_publisher_type: trusted_publisher.trusted_publisher_type,
+ trusted_publisher_attributes: trusted_publisher.trusted_publisher.as_json
+ }
+ }
+ end
+
+ assert_redirected_to rubygem_trusted_publishers_url(@rubygem.slug)
+ end
+
+ should "create rubygem trusted publisher when trusted publisher already exists" do
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 200, body: { id: "123456" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ github_action_trusted_publisher = create(:oidc_trusted_publisher_github_action)
+
+ assert_difference("OIDC::RubygemTrustedPublisher.count") do
+ post rubygem_trusted_publishers_url(@rubygem.slug), params: {
+ oidc_rubygem_trusted_publisher: {
+ trusted_publisher_type: github_action_trusted_publisher.class.polymorphic_name,
+ trusted_publisher_attributes: github_action_trusted_publisher.as_json
+ .slice("workflow_filename", "repository_owner", "repository_name").merge("environment" => "")
+ }
+ }
+ end
+
+ assert_redirected_to rubygem_trusted_publishers_url(@rubygem.slug)
+ end
+
+ should "error creating trusted publisher with type" do
+ assert_no_difference("OIDC::RubygemTrustedPublisher.count") do
+ post rubygem_trusted_publishers_url(@rubygem.slug), params: {
+ oidc_rubygem_trusted_publisher: {
+ trusted_publisher_type: "Hash",
+ trusted_publisher_attributes: { repository_owner: "example" }
+ }
+ }
+
+ assert_response :redirect
+ assert_equal "Unsupported trusted publisher type", flash[:error]
+ end
+ end
+
+ should "error creating trusted publisher with unknown repository owner" do
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 404, body: { message: "Not Found" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ assert_no_difference("OIDC::RubygemTrustedPublisher.count") do
+ post rubygem_trusted_publishers_url(@rubygem.slug), params: {
+ oidc_rubygem_trusted_publisher: {
+ trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name,
+ trusted_publisher_attributes: { repository_owner: "example" }
+ }
+ }
+
+ assert_response :unprocessable_entity
+ assert_equal [
+ "Trusted publisher repository name can't be blank",
+ "Trusted publisher workflow filename can't be blank",
+ "Trusted publisher repository owner can't be blank"
+ ].to_sentence, flash[:error]
+ end
+ end
+
+ should "error creating invalid trusted publisher" do
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ assert_no_difference("OIDC::RubygemTrustedPublisher.count") do
+ post rubygem_trusted_publishers_url(@rubygem.slug), params: {
+ oidc_rubygem_trusted_publisher: {
+ trusted_publisher_type: OIDC::TrustedPublisher::GitHubAction.polymorphic_name,
+ trusted_publisher_attributes: { repository_name: "rubygem1", repository_owner: "example", workflow_filename: "ci.NO" }
+ }
+ }
+
+ assert_response :unprocessable_entity
+ assert_equal ["Trusted publisher workflow filename must end with .yml or .yaml"].to_sentence, flash[:error]
+ end
+ end
+
+ should "destroy trusted publisher" do
+ assert_difference("OIDC::RubygemTrustedPublisher.count", -1) do
+ delete rubygem_trusted_publisher_url(@rubygem.slug, @trusted_publisher)
+ end
+
+ assert_redirected_to rubygem_trusted_publishers_url(@rubygem.slug)
+
+ assert_raises ActiveRecord::RecordNotFound do
+ @trusted_publisher.reload
+ end
+ end
+ end
+
+ context "without a verified session" do
+ should "redirect index to verify" do
+ get rubygem_trusted_publishers_url(@rubygem.slug)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect new to verify" do
+ get new_rubygem_trusted_publisher_url(@rubygem.slug)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect create to verify" do
+ post rubygem_trusted_publishers_url(@rubygem.slug)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+
+ should "redirect destroy to verify" do
+ delete new_rubygem_trusted_publisher_url(@rubygem.slug)
+
+ assert_response :redirect
+ assert_redirected_to verify_session_path
+ end
+ end
+end
diff --git a/test/integration/owner_test.rb b/test/integration/owner_test.rb
index 4ef8d97114a..d7aa11e91bb 100644
--- a/test/integration/owner_test.rb
+++ b/test/integration/owner_test.rb
@@ -114,6 +114,43 @@ class OwnerTest < SystemTest
assert_no_emails
end
+ test "verify using webauthn" do
+ create_webauthn_credential
+
+ visit sign_in_path
+ click_button "Authenticate with security device"
+ find(:css, ".header__popup-link")
+
+ visit rubygem_path(@rubygem.slug)
+ click_link "Ownership"
+
+ assert page.has_css? "#verify_password_password"
+
+ click_button "Authenticate with security device"
+
+ page.assert_text "add or remove owners"
+ end
+
+ test "verify failure using webauthn shows error" do
+ create_webauthn_credential
+
+ visit sign_in_path
+ click_button "Authenticate with security device"
+ find(:css, ".header__popup-link")
+
+ visit rubygem_path(@rubygem.slug)
+ click_link "Ownership"
+
+ assert page.has_css? "#verify_password_password"
+
+ @user.webauthn_credentials.find_each { |c| c.update!(external_id: "a") }
+
+ click_button "Authenticate with security device"
+
+ page.assert_text "Credentials required"
+ assert page.has_css? "#verify_password_password"
+ end
+
test "verify password again after 10 minutes" do
visit_ownerships_page
travel 15.minutes
@@ -219,6 +256,12 @@ class OwnerTest < SystemTest
refute page.has_content? @other_user.handle
end
+ teardown do
+ @authenticator&.remove!
+ Capybara.reset_sessions!
+ Capybara.use_default_driver
+ end
+
private
def owner_row(owner)
@@ -247,5 +290,7 @@ def sign_in_as(user)
fill_in "Email or Username", with: user.email
fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
click_button "Sign in"
+
+ find(:css, ".header__popup-link")
end
end
diff --git a/test/integration/password_reset_test.rb b/test/integration/password_reset_test.rb
index ea3fd87ea3a..dda77df7ae3 100644
--- a/test/integration/password_reset_test.rb
+++ b/test/integration/password_reset_test.rb
@@ -55,11 +55,33 @@ def forgot_password_with(email)
visit password_reset_link
+ assert page.has_content?("Sign out")
+
fill_in "Password", with: ""
click_button "Save this password"
assert page.has_content? "Password can't be blank."
- assert page.has_content? "Sign in"
+ assert page.has_content? "Reset password"
+
+ # try again
+ fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
+ click_button "Save this password"
+
+ assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD
+ end
+
+ test "resetting a password but waiting too long after token auth" do
+ forgot_password_with @user.email
+
+ visit password_reset_link
+
+ fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
+
+ travel 16.minutes do
+ click_button "Save this password"
+
+ assert page.has_content? "verification has expired. Please verify again."
+ end
end
test "resetting a password when signed in" do
@@ -78,6 +100,8 @@ def forgot_password_with(email)
visit password_reset_link
+ assert page.has_content?("Sign out")
+
fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
click_button "Save this password"
@@ -90,13 +114,15 @@ def forgot_password_with(email)
visit password_reset_link
+ refute page.has_content?("Sign out")
+
fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now
click_button "Authenticate"
+ assert page.has_content?("Sign out")
+
fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
click_button "Save this password"
-
- assert page.has_content?("Sign out")
end
test "resetting a password when mfa is enabled but mfa session is expired" do
@@ -124,8 +150,6 @@ def forgot_password_with(email)
assert page.has_content? "Security Device"
assert_not_nil page.find(".js-webauthn-session--form")[:action]
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
-
click_on "Authenticate with security device"
fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
@@ -143,6 +167,7 @@ def forgot_password_with(email)
visit password_reset_link
+ refute page.has_content? "Sign out"
assert page.has_content? "Multi-factor authentication"
assert page.has_content? "Security Device"
assert page.has_content? "Recovery code"
diff --git a/test/integration/push_test.rb b/test/integration/push_test.rb
index 46576cc0f15..af733526118 100644
--- a/test/integration/push_test.rb
+++ b/test/integration/push_test.rb
@@ -7,7 +7,7 @@ class PushTest < ActionDispatch::IntegrationTest
Dir.chdir(Dir.mktmpdir)
@key = "12345"
@user = create(:user)
- create(:api_key, user: @user, key: @key, push_rubygem: true)
+ create(:api_key, owner: @user, key: @key, push_rubygem: true)
end
test "pushing a gem" do
@@ -59,6 +59,76 @@ class PushTest < ActionDispatch::IntegrationTest
assert page.has_content?("2.0.0")
end
+ test "pushing a new version of a gem with a trusted publisher" do
+ rubygem = create(:rubygem, name: "sandworm", number: "1.0.0")
+ create(:ownership, rubygem: rubygem, user: @user)
+
+ rubygem_trusted_publisher = create(:oidc_rubygem_trusted_publisher, rubygem: rubygem)
+
+ @key = "543321"
+ create(:api_key, owner: rubygem_trusted_publisher.trusted_publisher, key: @key, push_rubygem: true)
+
+ build_gem "sandworm", "2.0.0"
+
+ push_gem "sandworm-2.0.0.gem"
+
+ assert_response :success
+
+ get rubygem_path("sandworm")
+
+ assert_response :success
+ page.assert_text("Pushed by")
+ page.assert_selector(:xpath, ".//img[@title=#{rubygem_trusted_publisher.trusted_publisher.name.inspect}]")
+ end
+
+ test "pushing a new gem with a pending trusted publisher" do
+ pending_trusted_publisher = create(:oidc_pending_trusted_publisher, rubygem_name: "sandworm", user: @user)
+
+ @key = "543321"
+ create(:api_key, owner: pending_trusted_publisher.trusted_publisher, key: @key, push_rubygem: true)
+
+ build_gem "sandworm", "2.0.0"
+
+ push_gem "sandworm-2.0.0.gem"
+
+ assert_response :success
+
+ get rubygem_path("sandworm")
+
+ assert_response :success
+ page.assert_text("Pushed by")
+ page.assert_selector(:xpath, ".//img[@title=#{pending_trusted_publisher.trusted_publisher.name.inspect}]")
+
+ rubygem = Rubygem.find_by!(name: "sandworm")
+
+ assert rubygem.owned_by?(@user)
+ assert rubygem.oidc_rubygem_trusted_publishers.exists?(trusted_publisher: pending_trusted_publisher.trusted_publisher)
+ end
+
+ test "pushing a new gem with a pending trusted publisher case insensitive" do
+ pending_trusted_publisher = create(:oidc_pending_trusted_publisher, rubygem_name: "SaNdWoRm", user: @user)
+
+ @key = "543321"
+ create(:api_key, owner: pending_trusted_publisher.trusted_publisher, key: @key, push_rubygem: true)
+
+ build_gem "sandworm", "2.0.0"
+
+ push_gem "sandworm-2.0.0.gem"
+
+ assert_response :success
+
+ get rubygem_path("sandworm")
+
+ assert_response :success
+ page.assert_text("Pushed by")
+ page.assert_selector(:xpath, ".//img[@title=#{pending_trusted_publisher.trusted_publisher.name.inspect}]")
+
+ rubygem = Rubygem.find_by!(name: "sandworm")
+
+ assert rubygem.owned_by?(@user)
+ assert rubygem.oidc_rubygem_trusted_publishers.exists?(trusted_publisher: pending_trusted_publisher.trusted_publisher)
+ end
+
test "pushing a gem with a known dependency" do
rubygem = create(:rubygem, name: "crysknife", number: "1.0.0")
@@ -439,6 +509,26 @@ class PushTest < ActionDispatch::IntegrationTest
assert_response :forbidden
end
+
+ should "fail when spec.date cannot Marshal.dump" do
+ build_gem_raw(file_name: "malicious.gem", spec: <<~YAML)
+ --- !ruby/object:Gem::Specification
+ specification_version: 100
+ name: book
+ version: '1'
+ platform: ruby
+ summary: 'malicious'
+ authors: [test@example.com]
+ date: !ruby/object:Time
+ a: 1
+ YAML
+
+ capture_io do
+ push_gem "malicious.gem"
+ end
+
+ assert_response :unprocessable_entity
+ end
end
def push_gem(path)
diff --git a/test/integration/rack_attack_test.rb b/test/integration/rack_attack_test.rb
index 392cfd36780..392e50827a0 100644
--- a/test/integration/rack_attack_test.rb
+++ b/test/integration/rack_attack_test.rb
@@ -87,7 +87,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
setup do
@rubygem = create(:rubygem, name: "test", number: "0.0.1")
create(:ownership, user: @user, rubygem: @rubygem)
- create(:api_key, key: "12334", push_rubygem: true, user: @user)
+ create(:api_key, key: "12334", push_rubygem: true, owner: @user)
end
should "allow gem push by ip" do
@@ -123,11 +123,11 @@ class RackAttackTest < ActionDispatch::IntegrationTest
@push_exp_throttle_level_key = "#{Rack::Attack::PUSH_EXP_THROTTLE_KEY}/#{level}:#{@ip_address}"
under_backoff_limit.times { Rack::Attack.cache.count(@push_exp_throttle_level_key, exp_base_limit_period**level) }
- @push_throttle_per_user_key = "#{Rack::Attack::PUSH_THROTTLE_PER_USER_KEY}/#{level}:#{@user.display_id}"
+ @push_throttle_per_user_key = "#{Rack::Attack::PUSH_THROTTLE_PER_USER_KEY}/#{level}:#{@user.to_gid}"
under_backoff_limit.times { Rack::Attack.cache.count(@push_throttle_per_user_key, exp_base_limit_period**level) }
end
- create(:api_key, key: "12334", push_rubygem: true, user: @user)
+ create(:api_key, key: "12334", push_rubygem: true, owner: @user)
post "/api/v1/gems",
params: gem_file("test-0.0.0.gem", &:read),
headers: { REMOTE_ADDR: @ip_address, HTTP_AUTHORIZATION: "12334", CONTENT_TYPE: "application/octet-stream" }
@@ -197,7 +197,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
stay_under_exponential_limit("api/ip")
- create(:api_key, key: "12334", add_owner: true, yank_rubygem: true, remove_owner: true, user: @user)
+ create(:api_key, key: "12334", add_owner: true, yank_rubygem: true, remove_owner: true, owner: @user)
@rubygem = create(:rubygem, name: "test", number: "0.0.1")
create(:ownership, user: @user, rubygem: @rubygem)
end
@@ -419,7 +419,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
should "throttle gem push by ip" do
exceed_push_limit_for("api/push/ip")
- create(:api_key, key: "12334", push_rubygem: true, user: @user)
+ create(:api_key, key: "12334", push_rubygem: true, owner: @user)
post "/api/v1/gems",
params: gem_file("test-1.0.0.gem", &:read),
@@ -434,7 +434,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
@mfa_max_period = { 1 => 300, 2 => 90_000 }
@user.enable_totp!(ROTP::Base32.random_base32, :ui_only)
@api_key = "12345"
- create(:api_key, key: @api_key, user: @user)
+ create(:api_key, key: @api_key, owner: @user)
end
Rack::Attack::EXP_BACKOFF_LEVELS.each do |level|
@@ -519,7 +519,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
should "throttle api key show by api key #{level}" do
freeze_time do
- exceed_exponential_api_key_limit_for("api/key/#{level}", @user.display_id, level)
+ exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level)
get "/api/v1/api_key.json", headers: { HTTP_AUTHORIZATION: @api_key }
assert_throttle_at(level)
@@ -537,7 +537,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
should "throttle api key create by api key #{level}" do
freeze_time do
- exceed_exponential_api_key_limit_for("api/key/#{level}", @user.display_id, level)
+ exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level)
post "/api/v1/api_key.json", headers: { HTTP_AUTHORIZATION: @api_key }
assert_throttle_at(level)
@@ -576,7 +576,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
should "throttle gem yank by api key #{level}" do
freeze_time do
- exceed_exponential_api_key_limit_for("api/key/#{level}", @user.display_id, level)
+ exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level)
delete "/api/v1/gems/yank", headers: { HTTP_AUTHORIZATION: @api_key }
assert_throttle_at(level)
@@ -594,7 +594,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
should "throttle owner add by api key #{level}" do
freeze_time do
- exceed_exponential_api_key_limit_for("api/key/#{level}", @user.display_id, level)
+ exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level)
post "/api/v1/gems/somegem/owners", headers: { HTTP_AUTHORIZATION: @api_key }
assert_throttle_at(level)
@@ -612,7 +612,7 @@ class RackAttackTest < ActionDispatch::IntegrationTest
should "throttle owner remove by api key #{level}" do
freeze_time do
- exceed_exponential_api_key_limit_for("api/key/#{level}", @user.display_id, level)
+ exceed_exponential_api_key_limit_for("api/key/#{level}", @user.to_gid, level)
delete "/api/v1/gems/somegem/owners", headers: { HTTP_AUTHORIZATION: @api_key }
assert_throttle_at(level)
diff --git a/test/integration/yank_test.rb b/test/integration/yank_test.rb
index 5ebf973cf73..985ad770f62 100644
--- a/test/integration/yank_test.rb
+++ b/test/integration/yank_test.rb
@@ -7,7 +7,7 @@ class YankTest < SystemTest
create(:ownership, user: @user, rubygem: @rubygem)
@user_api_key = "12345"
- create(:api_key, user: @user, key: @user_api_key, yank_rubygem: true)
+ create(:api_key, owner: @user, key: @user_api_key, yank_rubygem: true)
Dir.chdir(Dir.mktmpdir)
visit sign_in_path
diff --git a/test/jobs/delete_user_job_test.rb b/test/jobs/delete_user_job_test.rb
index b747538a526..3216f14edd9 100644
--- a/test/jobs/delete_user_job_test.rb
+++ b/test/jobs/delete_user_job_test.rb
@@ -23,7 +23,7 @@ class DeleteUserJobTest < ActiveJob::TestCase
test "succeeds with api key" do
user = create(:user)
- create(:api_key, user:)
+ create(:api_key, owner: user)
Mailer.expects(:deletion_complete).with(user.email).returns(mock(deliver_later: nil))
DeleteUserJob.perform_now(user:)
@@ -31,7 +31,7 @@ class DeleteUserJobTest < ActiveJob::TestCase
test "succeeds with api key used to push version" do
user = create(:user)
- api_key = create(:api_key, user:)
+ api_key = create(:api_key, owner: user)
create(:version, pusher_api_key: api_key, pusher: user)
Mailer.expects(:deletion_complete).with(user.email).returns(mock(deliver_later: nil))
diff --git a/test/jobs/refresh_oidc_providers_job_test.rb b/test/jobs/refresh_oidc_providers_job_test.rb
new file mode 100644
index 00000000000..9ad6f795703
--- /dev/null
+++ b/test/jobs/refresh_oidc_providers_job_test.rb
@@ -0,0 +1,16 @@
+require "test_helper"
+
+class RefreshOIDCProvidersJobTest < ActiveJob::TestCase
+ test "enqueues refresh jobs" do
+ provider1 = create(:oidc_provider)
+ provider2 = create(:oidc_provider)
+
+ assert_enqueued_jobs 2, only: RefreshOIDCProviderJob do
+ assert_enqueued_with(job: RefreshOIDCProviderJob, args: [{ provider: provider1 }]) do
+ assert_enqueued_with(job: RefreshOIDCProviderJob, args: [{ provider: provider2 }]) do
+ RefreshOIDCProvidersJob.perform_now
+ end
+ end
+ end
+ end
+end
diff --git a/test/jobs/store_version_contents_job_test.rb b/test/jobs/store_version_contents_job_test.rb
index 586ac2ea6ab..42344c95da0 100644
--- a/test/jobs/store_version_contents_job_test.rb
+++ b/test/jobs/store_version_contents_job_test.rb
@@ -8,7 +8,7 @@ class StoreVersionContentsJobTest < ActiveJob::TestCase
@gem = gem_file("bin_and_img-0.1.0.gem")
@user = create(:user)
- pusher = Pusher.new(create(:api_key, user: @user), @gem)
+ pusher = Pusher.new(create(:api_key, owner: @user), @gem)
assert pusher.process, "gem should be pushed successfully: #{pusher.code} #{pusher.message}"
@gem.rewind
diff --git a/test/jobs/upload_names_file_job_test.rb b/test/jobs/upload_names_file_job_test.rb
new file mode 100644
index 00000000000..09437026bcb
--- /dev/null
+++ b/test/jobs/upload_names_file_job_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class UploadNamesFileJobTest < ActiveJob::TestCase
+ make_my_diffs_pretty!
+
+ test "uploads the names file" do
+ version = create(:version, number: "0.0.1", required_ruby_version: ">= 2.0.0", required_rubygems_version: ">= 2.6.3")
+
+ perform_enqueued_jobs only: [UploadNamesFileJob] do
+ UploadNamesFileJob.perform_now
+ end
+
+ content = <<~INFO
+ ---
+ #{version.rubygem.name}
+ INFO
+
+ assert_equal content, RubygemFs.compact_index.get("names")
+
+ assert_equal(
+ {
+ metadata: {
+ "surrogate-control" => "max-age=3600, stale-while-revalidate=1800",
+ "surrogate-key" =>
+ "names s3-compact-index s3-names",
+ "sha256" => Digest::SHA256.base64digest(content),
+ "md5" => Digest::MD5.base64digest(content)
+ },
+ cache_control: "max-age=60, public",
+ content_type: "text/plain; charset=utf-8",
+ checksum_sha256: Digest::SHA256.base64digest(content),
+ content_md5: Digest::MD5.base64digest(content),
+ key: "names"
+ }, RubygemFs.compact_index.head("names")
+ )
+
+ assert_enqueued_with(job: FastlyPurgeJob, args: [{ key: "s3-names", soft: true }])
+ end
+
+ test "#good_job_concurrency_key" do
+ job = UploadNamesFileJob.new
+
+ assert_equal "UploadNamesFileJob", job.good_job_concurrency_key
+ end
+end
diff --git a/test/jobs/yank_version_contents_job_test.rb b/test/jobs/yank_version_contents_job_test.rb
index 661b88f16f6..49e324fa1be 100644
--- a/test/jobs/yank_version_contents_job_test.rb
+++ b/test/jobs/yank_version_contents_job_test.rb
@@ -6,7 +6,8 @@ class YankVersionContentsJobTest < ActiveJob::TestCase
RubygemFs.mock!
@user = create(:user)
- gem_file("bin_and_img-0.1.0.gem") { |gem| Pusher.new(@user, gem).process }
+ @api_key = create(:api_key, owner: @user)
+ gem_file("bin_and_img-0.1.0.gem") { |gem| Pusher.new(@api_key, gem).process }
@version = Version.last
StoreVersionContentsJob.perform_now(version: @version)
@rubygem = @version.rubygem
diff --git a/test/mailers/previews/mailer_preview.rb b/test/mailers/previews/mailer_preview.rb
index 2f5a833fa6f..ff7e7bf7945 100644
--- a/test/mailers/previews/mailer_preview.rb
+++ b/test/mailers/previews/mailer_preview.rb
@@ -30,7 +30,20 @@ def notifiers_changed
def gem_pushed
ownership = Ownership.where.not(user: nil).where(push_notifier: true).last
- Mailer.gem_pushed(ownership.user_id, ownership.rubygem.versions.last.id, ownership.user_id)
+ Mailer.gem_pushed(ownership.user, ownership.rubygem.versions.last.id, ownership.user_id)
+ end
+
+ def gem_pushed_by_trusted_publisher
+ ownership = Ownership.where.not(user: nil).where(push_notifier: true).last
+
+ Mailer.gem_pushed(OIDC::RubygemTrustedPublisher.first.trusted_publisher, ownership.rubygem.versions.last.id, ownership.user_id)
+ end
+
+ def gem_trusted_publisher_added
+ rubygem_trusted_publisher = OIDC::RubygemTrustedPublisher.last
+ created_by_user = User.last
+ notified_user = User.first
+ Mailer.gem_trusted_publisher_added(rubygem_trusted_publisher, created_by_user, notified_user)
end
def mfa_notification
@@ -84,12 +97,17 @@ def owner_added
end
def api_key_created
- api_key = ApiKey.last
+ api_key = ApiKey.where(owner_type: "User").last
+ Mailer.api_key_created(api_key.id)
+ end
+
+ def api_key_created_oidc_api_key_role
+ api_key = OIDC::IdToken.where.not(api_key_role: nil).last.api_key
Mailer.api_key_created(api_key.id)
end
def api_key_revoked
- api_key = ApiKey.last
+ api_key = ApiKey.where(owner_type: "User").last
Mailer.api_key_revoked(api_key.user.id, api_key.name, api_key.enabled_scopes.join(", "), "https://example.com")
end
diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb
index 1d7c0740f99..91ad24f188c 100644
--- a/test/models/api_key_test.rb
+++ b/test/models/api_key_test.rb
@@ -1,9 +1,8 @@
require "test_helper"
class ApiKeyTest < ActiveSupport::TestCase
- should belong_to :user
+ should belong_to :owner
should validate_presence_of(:name)
- should validate_presence_of(:user)
should validate_presence_of(:hashed_key)
should have_one(:api_key_rubygem_scope).dependent(:destroy)
@@ -11,6 +10,12 @@ class ApiKeyTest < ActiveSupport::TestCase
assert_predicate build(:api_key), :valid?
end
+ should "set owner to user by default" do
+ api_key = create(:api_key)
+
+ assert_equal api_key.user, api_key.owner
+ end
+
should "be invalid when name is empty string" do
api_key = build(:api_key, name: "")
@@ -48,12 +53,12 @@ class ApiKeyTest < ActiveSupport::TestCase
context "gem scope" do
setup do
@ownership = create(:ownership)
- @api_key = create(:api_key, push_rubygem: true, user: @ownership.user, ownership: @ownership)
- @api_key_no_gem_scope = create(:api_key, key: SecureRandom.hex(24), index_rubygems: true, user: @ownership.user)
+ @api_key = create(:api_key, push_rubygem: true, owner: @ownership.user, ownership: @ownership)
+ @api_key_no_gem_scope = create(:api_key, key: SecureRandom.hex(24), index_rubygems: true, owner: @ownership.user)
end
should "be invalid if non applicable API scope is enabled" do
- api_key = build(:api_key, index_rubygems: true, user: @ownership.user, ownership: @ownership)
+ api_key = build(:api_key, index_rubygems: true, owner: @ownership.user, ownership: @ownership)
refute_predicate api_key, :valid?
assert_contains api_key.errors[:rubygem], "scope can only be set for push/yank rubygem, and add/remove owner scopes"
@@ -61,7 +66,7 @@ class ApiKeyTest < ActiveSupport::TestCase
should "be valid if applicable API scope is enabled" do
%i[push_rubygem yank_rubygem add_owner remove_owner].each do |scope|
- api_key = build(:api_key, scope => true, user: @ownership.user, ownership: @ownership)
+ api_key = build(:api_key, scope => true, owner: @ownership.user, ownership: @ownership)
assert_predicate api_key, :valid?
end
@@ -89,7 +94,7 @@ class ApiKeyTest < ActiveSupport::TestCase
context "#rubygem_id=" do
should "set ownership to a gem" do
- api_key = create(:api_key, key: SecureRandom.hex(24), push_rubygem: true, user: @ownership.user, rubygem_id: @ownership.rubygem_id)
+ api_key = create(:api_key, key: SecureRandom.hex(24), push_rubygem: true, owner: @ownership.user, rubygem_id: @ownership.rubygem_id)
assert_equal @ownership.rubygem_id, api_key.rubygem_id
end
@@ -101,7 +106,7 @@ class ApiKeyTest < ActiveSupport::TestCase
end
should "add error when id is not associated with the user" do
- api_key = ApiKey.new(hashed_key: SecureRandom.hex(24), push_rubygem: true, user: @ownership.user, rubygem_id: -1)
+ api_key = ApiKey.new(hashed_key: SecureRandom.hex(24), push_rubygem: true, owner: @ownership.user, rubygem_id: -1)
assert_contains api_key.errors[:rubygem], "must be a gem that you are an owner of"
end
@@ -113,7 +118,7 @@ class ApiKeyTest < ActiveSupport::TestCase
:api_key,
key: SecureRandom.hex(24),
push_rubygem: true,
- user: @ownership.user,
+ owner: @ownership.user,
rubygem_name: @ownership.rubygem.name
)
@@ -131,7 +136,7 @@ class ApiKeyTest < ActiveSupport::TestCase
api_key = ApiKey.new(
hashed_key: SecureRandom.hex(24),
push_rubygem: true,
- user: @ownership.user,
+ owner: @ownership.user,
rubygem_name: rubygem.name
)
@@ -142,7 +147,7 @@ class ApiKeyTest < ActiveSupport::TestCase
api_key = ApiKey.new(
hashed_key: SecureRandom.hex(24),
push_rubygem: true,
- user: @ownership.user,
+ owner: @ownership.user,
rubygem_name: "invalid-gem-name"
)
@@ -179,7 +184,7 @@ class ApiKeyTest < ActiveSupport::TestCase
context "#soft_deleted_by_ownership?" do
should "return true if soft deleted gem name is present" do
ownership = create(:ownership)
- api_key = create(:api_key, push_rubygem: true, user: ownership.user, ownership: ownership)
+ api_key = create(:api_key, push_rubygem: true, owner: ownership.user, ownership: ownership)
api_key.soft_delete!(ownership: ownership)
assert_predicate api_key, :soft_deleted_by_ownership?
diff --git a/test/models/deletion_test.rb b/test/models/deletion_test.rb
index e636cc7556f..2c0a4ef89b0 100644
--- a/test/models/deletion_test.rb
+++ b/test/models/deletion_test.rb
@@ -6,8 +6,9 @@ class DeletionTest < ActiveSupport::TestCase
setup do
@user = create(:user)
+ @api_key = create(:api_key, owner: @user)
@gem_file = gem_file("test-0.0.0.gem")
- Pusher.new(@user, @gem_file).process
+ Pusher.new(@api_key, @gem_file).process
@gem_file.rewind
@version = Version.last
@spec_rz = RubygemFs.instance.get("quick/Marshal.4.8/#{@version.full_name}.gemspec.rz")
@@ -28,7 +29,7 @@ class DeletionTest < ActiveSupport::TestCase
context "association" do
subject { Deletion.new(version: @version, user: @user) }
- should belong_to :user
+ should belong_to(:user).without_validating_presence
end
context "with deleted gem" do
@@ -120,6 +121,7 @@ class DeletionTest < ActiveSupport::TestCase
deletion.valid?
assert_equal deletion.rubygem, @version.rubygem.name
+ assert_equal @version.id, deletion.version_id
end
context "with restored gem" do
diff --git a/test/models/oidc/api_key_role_test.rb b/test/models/oidc/api_key_role_test.rb
index 4752a2d77af..790f6a1ee16 100644
--- a/test/models/oidc/api_key_role_test.rb
+++ b/test/models/oidc/api_key_role_test.rb
@@ -29,16 +29,16 @@ class OIDC::ApiKeyRoleTest < ActiveSupport::TestCase
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
+ assert_same_elements [rubygem_role], OIDC::ApiKeyRole.for_rubygem(rubygem).to_a
+ assert_same_elements [@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_same_elements [role1, role2], OIDC::ApiKeyRole.for_scope("push_rubygem").to_a
+ assert_same_elements [role1], OIDC::ApiKeyRole.for_scope("yank_rubygem").to_a
assert_predicate OIDC::ApiKeyRole.for_scope("show_dashboard"), :none?
end
diff --git a/test/models/oidc/pending_trusted_publisher_test.rb b/test/models/oidc/pending_trusted_publisher_test.rb
new file mode 100644
index 00000000000..c1c6a79d603
--- /dev/null
+++ b/test/models/oidc/pending_trusted_publisher_test.rb
@@ -0,0 +1,29 @@
+require "test_helper"
+
+class OIDC::PendingTrustedPublisherTest < ActiveSupport::TestCase
+ setup do
+ @pending_trusted_publisher = build(:oidc_pending_trusted_publisher)
+ end
+ subject { @pending_trusted_publisher }
+
+ should belong_to(:trusted_publisher)
+ should belong_to(:user)
+
+ should validate_presence_of(:rubygem_name)
+ should validate_uniqueness_of(:rubygem_name).scoped_to(:trusted_publisher_id, :trusted_publisher_type).case_insensitive
+
+ test "validates rubygem name is available" do
+ publisher = build(:oidc_pending_trusted_publisher, rubygem_name: "foo")
+
+ assert_predicate publisher, :valid?
+
+ rubygem = create(:rubygem, name: "foo")
+
+ assert_predicate publisher, :valid?
+
+ create(:version, rubygem: rubygem)
+
+ refute_predicate publisher, :valid?
+ assert_equal ["is already in use"], publisher.errors[:rubygem_name]
+ end
+end
diff --git a/test/models/oidc/rubygem_trusted_publisher_test.rb b/test/models/oidc/rubygem_trusted_publisher_test.rb
new file mode 100644
index 00000000000..57e3f51c209
--- /dev/null
+++ b/test/models/oidc/rubygem_trusted_publisher_test.rb
@@ -0,0 +1,13 @@
+require "test_helper"
+
+class OIDC::RubygemTrustedPublisherTest < ActiveSupport::TestCase
+ setup do
+ @rubygem_trusted_publisher = build(:oidc_rubygem_trusted_publisher)
+ end
+ subject { @rubygem_trusted_publisher }
+
+ should belong_to(:rubygem)
+ should belong_to(:trusted_publisher)
+
+ should validate_uniqueness_of(:rubygem).scoped_to(:trusted_publisher_id, :trusted_publisher_type)
+end
diff --git a/test/models/oidc/trusted_publisher/github_action_test.rb b/test/models/oidc/trusted_publisher/github_action_test.rb
new file mode 100644
index 00000000000..b87e8e63e8f
--- /dev/null
+++ b/test/models/oidc/trusted_publisher/github_action_test.rb
@@ -0,0 +1,138 @@
+require "test_helper"
+
+class OIDC::TrustedPublisher::GitHubActionTest < ActiveSupport::TestCase
+ make_my_diffs_pretty!
+
+ should have_many(:rubygems)
+ should have_many(:rubygem_trusted_publishers)
+ should have_many(:api_keys).inverse_of(:owner)
+
+ should validate_presence_of(:repository_owner)
+ should validate_presence_of(:repository_name)
+ should validate_presence_of(:workflow_filename)
+ should validate_presence_of(:repository_owner_id)
+
+ test "validates publisher uniqueness" do
+ publisher = create(:oidc_trusted_publisher_github_action)
+ assert_raises(ActiveRecord::RecordInvalid) do
+ create(:oidc_trusted_publisher_github_action, repository_owner: publisher.repository_owner,
+ repository_name: publisher.repository_name, workflow_filename: publisher.workflow_filename,
+ repository_owner_id: publisher.repository_owner_id, environment: publisher.environment)
+ end
+ end
+
+ test ".for_claims" do
+ bar_other_owner_id = create(:oidc_trusted_publisher_github_action, repository_name: "bar")
+ bar_other_owner_id.update!(repository_owner_id: "654321")
+ bar = create(:oidc_trusted_publisher_github_action, repository_name: "bar")
+ bar_test = create(:oidc_trusted_publisher_github_action, repository_name: "bar", environment: "test")
+ _bar_dev = create(:oidc_trusted_publisher_github_action, repository_name: "bar", environment: "dev")
+ create(:oidc_trusted_publisher_github_action, repository_name: "foo")
+
+ claims = {
+ repository: "example/bar",
+ job_workflow_ref: "example/bar/.github/workflows/push_gem.yml@refs/heads/main",
+ ref: "refs/heads/main",
+ sha: "04de3558bc5861874a86f8fcd67e516554101e71",
+ repository_owner_id: "123456"
+ }
+
+ assert_equal bar, OIDC::TrustedPublisher::GitHubAction.for_claims(claims)
+ assert_equal bar, OIDC::TrustedPublisher::GitHubAction.for_claims(claims.merge(environment: nil))
+ assert_equal bar, OIDC::TrustedPublisher::GitHubAction.for_claims(claims.merge(environment: "other"))
+ assert_equal bar_test, OIDC::TrustedPublisher::GitHubAction.for_claims(claims.merge(environment: "test"))
+ end
+
+ test "#name" do
+ publisher = create(:oidc_trusted_publisher_github_action, repository_name: "bar")
+
+ assert_equal "GitHub Actions example/bar @ .github/workflows/push_gem.yml", publisher.name
+
+ publisher.update!(environment: "test")
+
+ assert_equal "GitHub Actions example/bar @ .github/workflows/push_gem.yml (test)", publisher.name
+ end
+
+ test "#owns_gem?" do
+ rubygem1 = create(:rubygem)
+ rubygem2 = create(:rubygem)
+
+ publisher = create(:oidc_trusted_publisher_github_action)
+ create(:oidc_rubygem_trusted_publisher, trusted_publisher: publisher, rubygem: rubygem1)
+
+ assert publisher.owns_gem?(rubygem1)
+ refute publisher.owns_gem?(rubygem2)
+ end
+
+ test "#to_access_policy" do
+ publisher = create(:oidc_trusted_publisher_github_action, repository_name: "rubygem1")
+
+ assert_equal(
+ {
+ statements: [
+ {
+ effect: "allow",
+ principal: {
+ oidc: "https://token.actions.githubusercontent.com"
+ },
+ conditions: [
+ { operator: "string_equals", claim: "repository", value: "example/rubygem1" },
+ { operator: "string_equals", claim: "repository_owner_id", value: "123456" },
+ { operator: "string_equals", claim: "aud", value: Gemcutter::HOST },
+ { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@ref" }
+ ]
+ },
+ {
+ effect: "allow",
+ principal: {
+ oidc: "https://token.actions.githubusercontent.com"
+ },
+ conditions: [
+ { operator: "string_equals", claim: "repository", value: "example/rubygem1" },
+ { operator: "string_equals", claim: "repository_owner_id", value: "123456" },
+ { operator: "string_equals", claim: "aud", value: Gemcutter::HOST },
+ { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@sha" }
+ ]
+ }
+ ]
+ }.deep_stringify_keys,
+ publisher.to_access_policy({ ref: "ref", sha: "sha" }).as_json
+ )
+
+ publisher.update!(environment: "test")
+
+ assert_equal(
+ {
+ statements: [
+ {
+ effect: "allow",
+ principal: {
+ oidc: "https://token.actions.githubusercontent.com"
+ },
+ conditions: [
+ { operator: "string_equals", claim: "repository", value: "example/rubygem1" },
+ { operator: "string_equals", claim: "environment", value: "test" },
+ { operator: "string_equals", claim: "repository_owner_id", value: "123456" },
+ { operator: "string_equals", claim: "aud", value: Gemcutter::HOST },
+ { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@ref" }
+ ]
+ },
+ {
+ effect: "allow",
+ principal: {
+ oidc: "https://token.actions.githubusercontent.com"
+ },
+ conditions: [
+ { operator: "string_equals", claim: "repository", value: "example/rubygem1" },
+ { operator: "string_equals", claim: "environment", value: "test" },
+ { operator: "string_equals", claim: "repository_owner_id", value: "123456" },
+ { operator: "string_equals", claim: "aud", value: Gemcutter::HOST },
+ { operator: "string_equals", claim: "job_workflow_ref", value: "example/rubygem1/.github/workflows/push_gem.yml@sha" }
+ ]
+ }
+ ]
+ }.deep_stringify_keys,
+ publisher.to_access_policy({ ref: "ref", sha: "sha" }).as_json
+ )
+ end
+end
diff --git a/test/models/parallel_pusher_test.rb b/test/models/parallel_pusher_test.rb
index b24b33b858c..249fa9561fc 100644
--- a/test/models/parallel_pusher_test.rb
+++ b/test/models/parallel_pusher_test.rb
@@ -8,6 +8,7 @@ class ParallelPusherTest < ActiveSupport::TestCase
setup do
@fs = RubygemFs.mock!
@user = create(:user, email: "user@example.com")
+ @api_key = create(:api_key, owner: @user)
end
teardown do
@@ -22,7 +23,7 @@ class ParallelPusherTest < ActiveSupport::TestCase
Thread.new do
gem_file("hola-0.0.0.gem") do |gem1|
- Pusher.new(@user, gem1).process
+ Pusher.new(@api_key, gem1).process
end
ActiveRecord::Base.connection.close
latch.count_down
@@ -30,7 +31,7 @@ class ParallelPusherTest < ActiveSupport::TestCase
Thread.new do
gem_file("hola/hola-0.0.0.gem") do |gem2|
- Pusher.new(@user, gem2).process
+ Pusher.new(@api_key, gem2).process
end
ActiveRecord::Base.connection.close
latch.count_down
diff --git a/test/models/pusher_test.rb b/test/models/pusher_test.rb
index 23180b00935..bfa0daeddd1 100644
--- a/test/models/pusher_test.rb
+++ b/test/models/pusher_test.rb
@@ -5,8 +5,9 @@ class PusherTest < ActiveSupport::TestCase
setup do
@user = create(:user, email: "user@example.com")
+ @api_key = create(:api_key, owner: @user)
@gem = gem_file
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
# Ensure we test #log_pushing
@cutter.logger.level = :info
@@ -18,7 +19,7 @@ class PusherTest < ActiveSupport::TestCase
context "creating a new gemcutter" do
should "have some state" do
- assert_respond_to @cutter, :user
+ assert_respond_to @cutter, :owner
assert_respond_to @cutter, :version
assert_respond_to @cutter, :version_id
assert_respond_to @cutter, :spec
@@ -27,7 +28,7 @@ class PusherTest < ActiveSupport::TestCase
assert_respond_to @cutter, :rubygem
assert_respond_to @cutter, :body
- assert_equal @user, @cutter.user
+ assert_equal @user, @cutter.owner
end
should "initialize size from the gem" do
@@ -35,7 +36,7 @@ class PusherTest < ActiveSupport::TestCase
end
should "#inspect" do
- assert_equal "",
+ assert_equal "",
@cutter.inspect
end
@@ -133,7 +134,7 @@ class PusherTest < ActiveSupport::TestCase
should "not be able to pull spec with metadata containing bad ruby objects" do
@gem = gem_file("exploit.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
out, err = capture_io do
@cutter.pull_spec
end
@@ -152,7 +153,7 @@ class PusherTest < ActiveSupport::TestCase
# this isn't the kind of invalid that we're testing with this gem
Gem::Specification.any_instance.stubs(:authors).returns(["user@example.com"])
@gem = gem_file("legit-gem-0.0.1.gem.fake")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
@cutter.stubs(:save).never
@cutter.process
@@ -164,7 +165,7 @@ class PusherTest < ActiveSupport::TestCase
should "not be able to save a gem if the date is not valid" do
@gem = gem_file("bad-date-1.0.0.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
out, err = capture_io do
@cutter.process
end
@@ -241,7 +242,7 @@ class PusherTest < ActiveSupport::TestCase
should "not be able to save a gem if it is signed and has been tampered with" do
@gem = gem_file("valid_signature_tampered-0.0.1.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
@cutter.process
assert_includes @cutter.message, %(missing signing certificate)
@@ -250,7 +251,7 @@ class PusherTest < ActiveSupport::TestCase
should "not be able to save a gem if it is signed with an expired signing certificate" do
@gem = gem_file("expired_signature-0.0.0.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
@cutter.process
assert_includes @cutter.message, %(not valid after 2021-07-08 08:21:01 UTC)
@@ -269,7 +270,7 @@ class PusherTest < ActiveSupport::TestCase
spec.cert_chain = two_cert_chain(signing_key: signing_key)
end
- @cutter = Pusher.new(@user, File.open(gem_file))
+ @cutter = Pusher.new(@api_key, File.open(gem_file))
@cutter.process
assert_equal 200, @cutter.code
@@ -289,7 +290,7 @@ class PusherTest < ActiveSupport::TestCase
Gem::Security::SigningPolicy.verify_root = old_verify_root_policy
end
- @cutter = Pusher.new(@user, File.open(gem_file))
+ @cutter = Pusher.new(@api_key, File.open(gem_file))
@cutter.process
assert_includes @cutter.message, %(CN=Root not valid after)
@@ -304,7 +305,7 @@ class PusherTest < ActiveSupport::TestCase
should "not be able to pull spec with metadata containing bad ruby symbols" do
["1.0.0", "2.0.0", "3.0.0", "4.0.0"].each do |version|
@gem = gem_file("dos-#{version}.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
out, err = capture_io do
@cutter.pull_spec
end
@@ -320,7 +321,7 @@ class PusherTest < ActiveSupport::TestCase
should "be able to pull spec with metadata containing aliases" do
@gem = gem_file("aliases-0.0.0.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
@cutter.pull_spec
assert_not_nil @cutter.spec
@@ -329,7 +330,7 @@ class PusherTest < ActiveSupport::TestCase
should "not be able to pull spec when no data available" do
@gem = gem_file("aliases-nodata-0.0.1.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
@cutter.pull_spec
assert_includes @cutter.message, %{package content (data.tar.gz) is missing}
@@ -495,6 +496,20 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
assert @cutter.authorize
end
+ should "be false if rubygem is new and api key has unexpected owner type" do
+ @cutter.stubs(:rubygem).returns Rubygem.new
+
+ owner = stub("owner")
+ @api_key.update_columns(owner_id: 0, owner_type: "stub")
+ @cutter.stubs(:owner).returns owner
+ owner.expects(:owns_gem?).with(@cutter.rubygem).returns(false)
+
+ refute @cutter.authorize
+ assert_equal "You are not allowed to push this gem.",
+ @cutter.message
+ assert_equal 403, @cutter.code
+ end
+
context "with a existing rubygem" do
setup do
@rubygem = create(:rubygem, name: "the_gem_name")
@@ -520,6 +535,18 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
assert_equal 403, @cutter.code
end
+ should "be false if api key has unexpected owner type" do
+ owner = stub("owner")
+ @api_key.update_columns(owner_id: 0, owner_type: "stub")
+ @cutter.stubs(:owner).returns owner
+ owner.expects(:owns_gem?).with(@rubygem).returns(false)
+
+ refute @cutter.authorize
+ assert_equal "You are not allowed to push this gem.",
+ @cutter.message
+ assert_equal 403, @cutter.code
+ end
+
should "be true if not owned by user but no indexed versions exist" do
create(:version, rubygem: @rubygem, number: "0.1.1", indexed: false)
@@ -529,7 +556,7 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
context "version metadata has rubygems_mfa_required set" do
setup do
spec = mock
- spec.expects(:metadata).returns({ "rubygems_mfa_required" => true })
+ spec.stubs(:metadata).returns({ "rubygems_mfa_required" => true })
@cutter.stubs(:spec).returns spec
metadata = { "rubygems_mfa_required" => "true" }
@@ -539,6 +566,24 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
should "be false if user has no mfa setup" do
refute @cutter.verify_mfa_requirement
end
+
+ should "be true if user has ui_and_api mfa but API key does not require MFA" do
+ @user.enable_totp!("abc123", User.mfa_levels["ui_and_api"])
+
+ assert_predicate @cutter, :verify_mfa_requirement
+ end
+
+ should "be true if user has ui_only mfa but API key does not require MFA" do
+ @user.enable_totp!("abc123", User.mfa_levels["ui_only"])
+
+ assert_predicate @cutter, :verify_mfa_requirement
+ end
+
+ should "be true if user has ui_and_gem_signin mfa but API key does not require MFA" do
+ @user.enable_totp!("abc123", User.mfa_levels["ui_and_gem_signin"])
+
+ assert_predicate @cutter, :verify_mfa_requirement
+ end
end
end
end
@@ -680,7 +725,7 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
context "pushing to s3 fails" do
setup do
@gem = gem_file("test-1.0.0.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
@fs = RubygemFs.s3!("https://some.host")
s3_exception = Aws::S3::Errors::ServiceError.new("stub raises", "something went wrong")
Aws::S3::Client.any_instance.stubs(:put_object).with(any_parameters).raises(s3_exception)
@@ -700,6 +745,23 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
end
end
+ context "saving fails with ArgumentError" do
+ setup do
+ @gem = gem_file("test-1.0.0.gem")
+ @cutter = Pusher.new(@api_key, @gem)
+ @cutter.stubs(:update).raises(ArgumentError.new("some message"))
+ @cutter.process
+ end
+
+ should "not create rubygem or version" do
+ rubygem = Rubygem.find_by(name: "test")
+ expected_message = "There was a problem saving your gem. some message"
+
+ assert_equal expected_message, @cutter.message
+ assert_nil rubygem
+ end
+ end
+
context "has a scoped gem" do
setup do
@rubygem = create(:rubygem)
@@ -707,7 +769,8 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
should "pushes gem if scoped to the same gem" do
create(:version, rubygem: @rubygem, number: "0.1.1", indexed: false)
- cutter = Pusher.new(@user, @gem, "", @rubygem)
+ @api_key.ownership = create(:ownership, rubygem: @rubygem, user: @user)
+ cutter = Pusher.new(@api_key, @gem, "")
cutter.stubs(:rubygem).returns @rubygem
assert cutter.verify_gem_scope
@@ -715,7 +778,8 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
should "does not push gem if scoped to another gem" do
create(:version, rubygem: @rubygem, number: "0.1.1", indexed: false)
- cutter = Pusher.new(@user, @gem, "", create(:rubygem))
+ @api_key.ownership = create(:ownership, rubygem: create(:rubygem), user: @user)
+ cutter = Pusher.new(@api_key, @gem, "")
cutter.stubs(:rubygem).returns @rubygem
refute cutter.verify_gem_scope
@@ -725,7 +789,7 @@ def two_cert_chain(signing_key:, root_not_before: Time.current, cert_not_before:
context "the gem has been signed and not tampered with" do
setup do
@gem = gem_file("valid_signature-0.0.0.gem")
- @cutter = Pusher.new(@user, @gem)
+ @cutter = Pusher.new(@api_key, @gem)
@cutter.process
end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 6a8fc390478..ce34f8caf9d 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -395,7 +395,7 @@ class UserTest < ActiveSupport::TestCase
context "blocking user with api key" do
setup do
- api_key = create(:api_key, user: @user)
+ api_key = create(:api_key, owner: @user)
# simulate gem pushed using api key to ensure
# user with pushed gems can be blocked
create(:version, pusher: @user, pusher_api_key: api_key)
diff --git a/test/models/web_hook_test.rb b/test/models/web_hook_test.rb
index 272f39eb663..87468c90ddf 100644
--- a/test/models/web_hook_test.rb
+++ b/test/models/web_hook_test.rb
@@ -181,7 +181,7 @@ class WebHookTest < ActiveSupport::TestCase
should "include an Authorization header for a user with many API keys" do
@hook.user.update(api_key: nil)
- create(:api_key, user: @hook.user)
+ create(:api_key, owner: @hook.user)
authorization = Digest::SHA2.hexdigest(@rubygem.name + @version.number + @hook.user.api_keys.first.hashed_key)
stub_request(:post, @url).with(headers: { "Authorization" => authorization })
diff --git a/test/policies/oidc/pending_trusted_publisher_policy_test.rb b/test/policies/oidc/pending_trusted_publisher_policy_test.rb
new file mode 100644
index 00000000000..74c921e3bdd
--- /dev/null
+++ b/test/policies/oidc/pending_trusted_publisher_policy_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class OIDC::PendingTrustedPublisherPolicyTest < ActiveSupport::TestCase
+ setup do
+ @pending_trusted_publisher = create(:oidc_pending_trusted_publisher)
+
+ @admin = create(:admin_github_user, :is_admin)
+ @non_admin = create(:admin_github_user)
+ end
+
+ def test_scope
+ assert_equal [@pending_trusted_publisher], Pundit.policy_scope!(
+ @admin,
+ OIDC::PendingTrustedPublisher
+ ).to_a
+ end
+
+ def test_avo_index
+ assert_predicate Pundit.policy!(@admin, OIDC::PendingTrustedPublisher), :avo_index?
+ refute_predicate Pundit.policy!(@non_admin, OIDC::PendingTrustedPublisher), :avo_index?
+ end
+
+ def test_avo_show
+ assert_predicate Pundit.policy!(@admin, @pending_trusted_publisher), :avo_show?
+ refute_predicate Pundit.policy!(@non_admin, @pending_trusted_publisher), :avo_show?
+ end
+
+ def test_avo_create
+ refute_predicate Pundit.policy!(@admin, OIDC::PendingTrustedPublisher), :avo_create?
+ refute_predicate Pundit.policy!(@non_admin, OIDC::PendingTrustedPublisher), :avo_create?
+ end
+
+ def test_avo_update
+ refute_predicate Pundit.policy!(@admin, @pending_trusted_publisher), :avo_update?
+ refute_predicate Pundit.policy!(@non_admin, @pending_trusted_publisher), :avo_update?
+ end
+
+ def test_avo_destroy
+ refute_predicate Pundit.policy!(@admin, @pending_trusted_publisher), :avo_destroy?
+ refute_predicate Pundit.policy!(@non_admin, @pending_trusted_publisher), :avo_destroy?
+ end
+end
diff --git a/test/policies/oidc/rubygem_trusted_publisher_policy_test.rb b/test/policies/oidc/rubygem_trusted_publisher_policy_test.rb
new file mode 100644
index 00000000000..1ec4e6c33cc
--- /dev/null
+++ b/test/policies/oidc/rubygem_trusted_publisher_policy_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class OIDC::RubygemTrustedPublisherPolicyTest < ActiveSupport::TestCase
+ setup do
+ @rubygem_trusted_publisher = create(:oidc_rubygem_trusted_publisher)
+
+ @admin = create(:admin_github_user, :is_admin)
+ @non_admin = create(:admin_github_user)
+ end
+
+ def test_scope
+ assert_equal [@rubygem_trusted_publisher], Pundit.policy_scope!(
+ @admin,
+ OIDC::RubygemTrustedPublisher
+ ).to_a
+ end
+
+ def test_avo_index
+ assert_predicate Pundit.policy!(@admin, OIDC::RubygemTrustedPublisher), :avo_index?
+ refute_predicate Pundit.policy!(@non_admin, OIDC::RubygemTrustedPublisher), :avo_index?
+ end
+
+ def test_avo_show
+ assert_predicate Pundit.policy!(@admin, @rubygem_trusted_publisher), :avo_show?
+ refute_predicate Pundit.policy!(@non_admin, @rubygem_trusted_publisher), :avo_show?
+ end
+
+ def test_avo_create
+ refute_predicate Pundit.policy!(@admin, OIDC::RubygemTrustedPublisher), :avo_create?
+ refute_predicate Pundit.policy!(@non_admin, OIDC::RubygemTrustedPublisher), :avo_create?
+ end
+
+ def test_avo_update
+ refute_predicate Pundit.policy!(@admin, @rubygem_trusted_publisher), :avo_update?
+ refute_predicate Pundit.policy!(@non_admin, @rubygem_trusted_publisher), :avo_update?
+ end
+
+ def test_avo_destroy
+ refute_predicate Pundit.policy!(@admin, @rubygem_trusted_publisher), :avo_destroy?
+ refute_predicate Pundit.policy!(@non_admin, @rubygem_trusted_publisher), :avo_destroy?
+ end
+end
diff --git a/test/policies/oidc/trusted_publisher/github_action_policy_test.rb b/test/policies/oidc/trusted_publisher/github_action_policy_test.rb
new file mode 100644
index 00000000000..4d00f78ab02
--- /dev/null
+++ b/test/policies/oidc/trusted_publisher/github_action_policy_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class OIDC::TrustedPublisher::GitHubActionPolicyTest < ActiveSupport::TestCase
+ setup do
+ @trusted_publisher_github_action = create(:oidc_trusted_publisher_github_action)
+
+ @admin = create(:admin_github_user, :is_admin)
+ @non_admin = create(:admin_github_user)
+ end
+
+ def test_scope
+ assert_equal [@trusted_publisher_github_action], Pundit.policy_scope!(
+ @admin,
+ OIDC::TrustedPublisher::GitHubAction
+ ).to_a
+ end
+
+ def test_avo_index
+ assert_predicate Pundit.policy!(@admin, OIDC::TrustedPublisher::GitHubAction), :avo_index?
+ refute_predicate Pundit.policy!(@non_admin, OIDC::TrustedPublisher::GitHubAction), :avo_index?
+ end
+
+ def test_avo_show
+ assert_predicate Pundit.policy!(@admin, @trusted_publisher_github_action), :avo_show?
+ refute_predicate Pundit.policy!(@non_admin, @trusted_publisher_github_action), :avo_show?
+ end
+
+ def test_avo_create
+ refute_predicate Pundit.policy!(@admin, OIDC::TrustedPublisher::GitHubAction), :avo_create?
+ refute_predicate Pundit.policy!(@non_admin, OIDC::TrustedPublisher::GitHubAction), :avo_create?
+ end
+
+ def test_avo_update
+ refute_predicate Pundit.policy!(@admin, @trusted_publisher_github_action), :avo_update?
+ refute_predicate Pundit.policy!(@non_admin, @trusted_publisher_github_action), :avo_update?
+ end
+
+ def test_avo_destroy
+ refute_predicate Pundit.policy!(@admin, @trusted_publisher_github_action), :avo_destroy?
+ refute_predicate Pundit.policy!(@non_admin, @trusted_publisher_github_action), :avo_destroy?
+ end
+end
diff --git a/test/policies/webauthn_credential_policy_test.rb b/test/policies/webauthn_credential_policy_test.rb
new file mode 100644
index 00000000000..0d947926379
--- /dev/null
+++ b/test/policies/webauthn_credential_policy_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class WebauthnCredentialPolicyTest < ActiveSupport::TestCase
+ setup do
+ @webauthn_credential = FactoryBot.create(:webauthn_credential)
+ @admin = FactoryBot.create(:admin_github_user, :is_admin)
+ @non_admin = FactoryBot.create(:admin_github_user)
+ end
+
+ def test_scope
+ assert_equal [@webauthn_credential], Pundit.policy_scope!(
+ @admin,
+ WebauthnCredential
+ ).to_a
+ end
+
+ def test_avo_index
+ refute_predicate Pundit.policy!(@admin, WebauthnCredential), :avo_index?
+ refute_predicate Pundit.policy!(@non_admin, WebauthnCredential), :avo_index?
+ end
+
+ def test_avo_show
+ assert_predicate Pundit.policy!(@admin, @webauthn_credential), :avo_show?
+ refute_predicate Pundit.policy!(@non_admin, @webauthn_credential), :avo_show?
+ end
+
+ def test_avo_create
+ refute_predicate Pundit.policy!(@admin, WebauthnCredential), :avo_create?
+ refute_predicate Pundit.policy!(@non_admin, WebauthnCredential), :avo_create?
+ end
+
+ def test_avo_update
+ refute_predicate Pundit.policy!(@admin, @webauthn_credential), :avo_update?
+ refute_predicate Pundit.policy!(@non_admin, @webauthn_credential), :avo_update?
+ end
+
+ def test_avo_destroy
+ refute_predicate Pundit.policy!(@admin, @webauthn_credential), :avo_destroy?
+ refute_predicate Pundit.policy!(@non_admin, @webauthn_credential), :avo_destroy?
+ end
+end
diff --git a/test/policies/webauthn_verification_policy_test.rb b/test/policies/webauthn_verification_policy_test.rb
new file mode 100644
index 00000000000..3afef2dc596
--- /dev/null
+++ b/test/policies/webauthn_verification_policy_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class WebauthnVerificationPolicyTest < ActiveSupport::TestCase
+ setup do
+ @webauthn_verification = FactoryBot.create(:webauthn_verification)
+ @admin = FactoryBot.create(:admin_github_user, :is_admin)
+ @non_admin = FactoryBot.create(:admin_github_user)
+ end
+
+ def test_scope
+ assert_equal [@webauthn_verification], Pundit.policy_scope!(
+ @admin,
+ WebauthnVerification
+ ).to_a
+ end
+
+ def test_avo_index
+ refute_predicate Pundit.policy!(@admin, WebauthnVerification), :avo_index?
+ refute_predicate Pundit.policy!(@non_admin, WebauthnVerification), :avo_index?
+ end
+
+ def test_avo_show
+ assert_predicate Pundit.policy!(@admin, @webauthn_verification), :avo_show?
+ refute_predicate Pundit.policy!(@non_admin, @webauthn_verification), :avo_show?
+ end
+
+ def test_avo_create
+ refute_predicate Pundit.policy!(@admin, WebauthnVerification), :avo_create?
+ refute_predicate Pundit.policy!(@non_admin, WebauthnVerification), :avo_create?
+ end
+
+ def test_avo_update
+ refute_predicate Pundit.policy!(@admin, @webauthn_verification), :avo_update?
+ refute_predicate Pundit.policy!(@non_admin, @webauthn_verification), :avo_update?
+ end
+
+ def test_avo_destroy
+ refute_predicate Pundit.policy!(@admin, @webauthn_verification), :avo_destroy?
+ refute_predicate Pundit.policy!(@non_admin, @webauthn_verification), :avo_destroy?
+ end
+end
diff --git a/test/system/api_keys_test.rb b/test/system/api_keys_test.rb
index ffb47eba928..55adbda6a62 100644
--- a/test/system/api_keys_test.rb
+++ b/test/system/api_keys_test.rb
@@ -29,7 +29,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "creating new api key from index" do
- create(:api_key, user: @user)
+ create(:api_key, owner: @user)
visit_profile_api_keys_path
click_button "New API key"
@@ -132,7 +132,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "update api key scope" do
- api_key = create(:api_key, user: @user)
+ api_key = create(:api_key, owner: @user)
visit_profile_api_keys_path
click_button "Edit"
@@ -148,7 +148,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "update api key gem scope" do
- api_key = create(:api_key, push_rubygem: true, user: @user, ownership: @ownership)
+ api_key = create(:api_key, push_rubygem: true, owner: @user, ownership: @ownership)
visit_profile_api_keys_path
click_button "Edit"
@@ -163,7 +163,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "update gem scoped api key with applicable scopes removed" do
- api_key = create(:api_key, push_rubygem: true, user: @user, ownership: @ownership)
+ api_key = create(:api_key, push_rubygem: true, owner: @user, ownership: @ownership)
visit_profile_api_keys_path
click_button "Edit"
@@ -179,7 +179,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "update gem scoped api key to another applicable scope" do
- api_key = create(:api_key, push_rubygem: true, user: @user, ownership: @ownership)
+ api_key = create(:api_key, push_rubygem: true, owner: @user, ownership: @ownership)
visit_profile_api_keys_path
click_button "Edit"
@@ -197,7 +197,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "update api key gem scope to a gem the user does not own" do
- api_key = create(:api_key, push_rubygem: true, user: @user, ownership: @ownership)
+ api_key = create(:api_key, push_rubygem: true, owner: @user, ownership: @ownership)
@another_ownership = create(:ownership, user: @user, rubygem: create(:rubygem, name: "another_gem"))
visit_profile_api_keys_path
@@ -218,7 +218,7 @@ class ApiKeysTest < ApplicationSystemTestCase
test "update api key with MFA UI enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_only)
- api_key = create(:api_key, user: @user)
+ api_key = create(:api_key, owner: @user)
visit_profile_api_keys_path
click_button "Edit"
@@ -235,7 +235,7 @@ class ApiKeysTest < ApplicationSystemTestCase
test "update api key with MFA UI and API enabled" do
@user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api)
- api_key = create(:api_key, user: @user)
+ api_key = create(:api_key, owner: @user)
visit_profile_api_keys_path
click_button "Edit"
@@ -251,7 +251,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "deleting api key" do
- create(:api_key, user: @user)
+ create(:api_key, owner: @user)
visit_profile_api_keys_path
click_button "Delete"
@@ -262,7 +262,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "deleting all api key" do
- create(:api_key, user: @user)
+ create(:api_key, owner: @user)
visit_profile_api_keys_path
click_button "Reset"
@@ -273,7 +273,7 @@ class ApiKeysTest < ApplicationSystemTestCase
end
test "gem ownership removed displays api key as invalid" do
- api_key = create(:api_key, push_rubygem: true, user: @user, ownership: @ownership)
+ api_key = create(:api_key, push_rubygem: true, owner: @user, ownership: @ownership)
visit_profile_api_keys_path
refute page.has_css? ".owners__row__invalid"
diff --git a/test/system/avo/rubygems_test.rb b/test/system/avo/rubygems_test.rb
index 98351428ab7..49a9cacf319 100644
--- a/test/system/avo/rubygems_test.rb
+++ b/test/system/avo/rubygems_test.rb
@@ -326,6 +326,27 @@ def sign_in_as(user)
assert_not_nil Audit.last
end
+ test "upload names file" do
+ admin_user = create(:admin_github_user, :is_admin)
+ sign_in_as admin_user
+
+ visit avo.resources_rubygems_path
+
+ _ = create(:version)
+
+ click_button "Actions"
+ click_on "Upload Names File"
+ fill_in "Comment", with: "A nice long comment"
+
+ assert_enqueued_jobs 1, only: UploadNamesFileJob do
+ click_button "Upload"
+
+ page.assert_text "Upload job scheduled"
+ end
+
+ assert_not_nil Audit.last
+ end
+
test "upload info file" do
admin_user = create(:admin_github_user, :is_admin)
sign_in_as admin_user
diff --git a/test/system/avo/versions_test.rb b/test/system/avo/versions_test.rb
index dfc2dfa8246..c72c89a0cd8 100644
--- a/test/system/avo/versions_test.rb
+++ b/test/system/avo/versions_test.rb
@@ -112,7 +112,8 @@ def sign_in_as(user)
"number" => [version_attributes[:number], nil],
"platform" => ["ruby", nil],
"created_at" => [deletion.created_at.as_json, nil],
- "updated_at" => [deletion.updated_at.as_json, nil]
+ "updated_at" => [deletion.updated_at.as_json, nil],
+ "version_id" => [version.id, nil]
},
"unchanged" => {}
}
diff --git a/test/system/multifactor_auths_test.rb b/test/system/multifactor_auths_test.rb
index 80f474bc63f..a53eec596bb 100644
--- a/test/system/multifactor_auths_test.rb
+++ b/test/system/multifactor_auths_test.rb
@@ -42,7 +42,7 @@ class MultifactorAuthsTest < ApplicationSystemTestCase
end
test "user with mfa disabled gets redirected back to profile api keys pages after setting up mfa" do
- create(:api_key, push_rubygem: true, user: @user, ownership: @ownership)
+ create(:api_key, push_rubygem: true, owner: @user, ownership: @ownership)
redirect_test_mfa_disabled(profile_api_keys_path) { verify_password }
end
@@ -75,7 +75,7 @@ class MultifactorAuthsTest < ApplicationSystemTestCase
end
test "user with weak level mfa gets redirected back to profile api keys pages after setting up mfa" do
- create(:api_key, push_rubygem: true, user: @user, ownership: @ownership)
+ create(:api_key, push_rubygem: true, owner: @user, ownership: @ownership)
redirect_test_mfa_weak_level(profile_api_keys_path) { verify_password }
end
diff --git a/test/system/oidc_test.rb b/test/system/oidc_test.rb
index 7c4b17cbff4..2f42f6eb0d5 100644
--- a/test/system/oidc_test.rb
+++ b/test/system/oidc_test.rb
@@ -214,4 +214,142 @@ def verify_session # rubocop:disable Minitest/TestMethodName
}
), role.reload.as_json.slice(*expected.keys))
end
+
+ test "creating rubygem trusted publishers" do
+ rubygem = create(:rubygem, name: "rubygem0")
+ create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem0" })
+
+ visit new_rubygem_trusted_publisher_path(rubygem.slug)
+
+ assert_text "Please sign in to continue."
+
+ sign_in
+ visit new_rubygem_trusted_publisher_path(rubygem.slug)
+ verify_session
+
+ assert_text "forbidden"
+
+ create(:ownership, rubygem: rubygem, user: @user)
+
+ visit rubygem_trusted_publishers_path(rubygem.slug)
+
+ page.assert_selector "h1", text: "Trusted Publishers"
+ page.assert_text("Trusted publishers for rubygem0")
+ page.assert_text "NO RUBYGEM TRUSTED PUBLISHERS FOUND"
+
+ stub_request(:get, "https://api.github.com/repos/example/rubygem0/contents/.github/workflows")
+ .to_return(status: 200, body: [
+ { name: "ci.yml", type: "file" },
+ { name: "push_rubygem.yml", type: "file" },
+ { name: "push_README.md", type: "file" },
+ { name: "push.yml", type: "directory" }
+ ].to_json, headers: { "Content-Type" => "application/json" })
+
+ click_button "Create"
+
+ page.assert_selector "h1", text: "New Trusted Publisher"
+
+ assert_field "Repository owner", with: "example"
+ assert_field "Repository name", with: "rubygem0"
+ assert_field "Workflow filename", with: "push_rubygem.yml"
+ assert_field "Environment", with: ""
+
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ click_button "Create Rubygem trusted publisher"
+
+ page.assert_text "Trusted Publisher created"
+ page.assert_selector "h1", text: "Trusted Publishers"
+ page.assert_text("Trusted publishers for rubygem0")
+ page.assert_text "GitHub Actions\nDelete\nGitHub Repository\nexample/rubygem0\nWorkflow Filename\npush_rubygem.yml"
+ end
+
+ test "deleting rubygem trusted publishers" do
+ rubygem = create(:rubygem, owners: [@user])
+ create(:oidc_rubygem_trusted_publisher, rubygem:)
+ create(:version, rubygem:)
+
+ sign_in
+ visit rubygem_trusted_publishers_path(rubygem.slug)
+ verify_session
+
+ click_button "Delete"
+
+ page.assert_text "Trusted Publisher deleted"
+ page.assert_text "NO RUBYGEM TRUSTED PUBLISHERS FOUND"
+ end
+
+ test "creating pending trusted publishers" do
+ rubygem = create(:rubygem, name: "rubygem0")
+ create(:version, rubygem: rubygem, metadata: { "source_code_uri" => "https://github.com/example/rubygem0" })
+
+ visit profile_oidc_pending_trusted_publishers_path
+
+ assert_text "Please sign in to continue."
+
+ sign_in
+ visit profile_oidc_pending_trusted_publishers_path
+ verify_session
+ click_button "Create"
+
+ page.assert_selector "h1", text: "New Pending Trusted Publisher"
+
+ click_button "Create"
+
+ page.assert_text "can't be blank"
+ page.assert_selector "h1", text: "New Pending Trusted Publisher"
+
+ page.fill_in "RubyGem name", with: "rubygem0"
+ page.fill_in "Repository owner", with: "example"
+ page.fill_in "Repository name", with: "rubygem1"
+ page.fill_in "Workflow filename", with: "push_rubygem.yml"
+ page.fill_in "Environment", with: "prod"
+
+ stub_request(:get, "https://api.github.com/users/example")
+ .to_return(status: 200, body: { id: "54321" }.to_json, headers: { "Content-Type" => "application/json" })
+
+ click_button "Create"
+
+ page.assert_text "RubyGem name is already in use"
+ page.assert_selector "h1", text: "New Pending Trusted Publisher"
+
+ assert_field "RubyGem name", with: "rubygem0"
+ assert_field "Repository owner", with: "example"
+ assert_field "Repository name", with: "rubygem1"
+ assert_field "Workflow filename", with: "push_rubygem.yml"
+ assert_field "Environment", with: "prod"
+
+ page.fill_in "RubyGem name", with: "rubygem1"
+
+ click_button "Create Pending trusted publisher"
+
+ page.assert_text "Pending Trusted Publisher created"
+ page.assert_selector "h1", text: "Pending Trusted Publishers"
+ page.assert_text <<~TEXT
+ rubygem1
+ Delete
+ GitHub Actions
+ Valid for about 12 hours
+ GitHub Repository
+ example/rubygem1
+ Workflow Filename
+ push_rubygem.yml
+ Environment
+ prod
+ TEXT
+ end
+
+ test "deleting pending trusted publishers" do
+ create(:oidc_pending_trusted_publisher, user: @user)
+
+ sign_in
+ visit profile_oidc_pending_trusted_publishers_path
+ verify_session
+
+ click_button "Delete"
+
+ page.assert_text "Pending Trusted Publisher deleted"
+ page.assert_text "NO PENDING TRUSTED PUBLISHERS FOUND"
+ end
end
diff --git a/test/system/sign_in_webauthn_test.rb b/test/system/sign_in_webauthn_test.rb
index 4152278e90c..c1540f3e861 100644
--- a/test/system/sign_in_webauthn_test.rb
+++ b/test/system/sign_in_webauthn_test.rb
@@ -12,10 +12,12 @@ class SignInWebauthnTest < ApplicationSystemTestCase
end
teardown do
- @authenticator.remove!
+ @authenticator&.remove!
+ Capybara.reset_sessions!
+ Capybara.use_default_driver
end
- test "sign in with webauthn" do
+ test "sign in with webauthn mfa" do
visit sign_in_path
fill_in "Email or Username", with: @user.email
@@ -25,15 +27,13 @@ class SignInWebauthnTest < ApplicationSystemTestCase
assert page.has_content? "Multi-factor authentication"
assert page.has_content? "Security Device"
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
-
click_on "Authenticate with security device"
assert page.has_content? "Dashboard"
refute page.has_content? "We now support security devices!"
end
- test "sign in with webauthn but it expired" do
+ test "sign in with webauthn mfa but it expired" do
visit sign_in_path
fill_in "Email or Username", with: @user.email
@@ -43,8 +43,6 @@ class SignInWebauthnTest < ApplicationSystemTestCase
assert page.has_content? "Multi-factor authentication"
assert page.has_content? "Security Device"
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
-
travel 30.minutes do
click_on "Authenticate with security device"
@@ -53,7 +51,25 @@ class SignInWebauthnTest < ApplicationSystemTestCase
end
end
- test "sign in with webauthn using recovery codes" do
+ test "sign in with webauthn mfa wrong user handle" do
+ visit sign_in_path
+
+ fill_in "Email or Username", with: @user.email
+ fill_in "Password", with: @user.password
+ click_button "Sign in"
+
+ assert page.has_content? "Multi-factor authentication"
+ assert page.has_content? "Security Device"
+
+ @user.update!(webauthn_id: "a")
+
+ click_on "Authenticate with security device"
+
+ refute page.has_content? "Dashboard"
+ assert page.has_content? "Sign in"
+ end
+
+ test "sign in with webauthn mfa using recovery codes" do
visit sign_in_path
fill_in "Email or Username", with: @user.email
@@ -68,4 +84,44 @@ class SignInWebauthnTest < ApplicationSystemTestCase
assert page.has_content? "Dashboard"
end
+
+ test "sign in with webauthn" do
+ visit sign_in_path
+
+ click_on "Authenticate with security device"
+
+ assert page.has_content? "Dashboard"
+ refute page.has_content? "We now support security devices!"
+ end
+
+ test "sign in with webauthn failure" do
+ visit sign_in_path
+
+ @user.webauthn_credentials.find_each { |c| c.update!(external_id: "a") }
+
+ click_on "Authenticate with security device"
+
+ refute page.has_content? "Dashboard"
+ end
+
+ test "sign in with webauthn user_handle changed failure" do
+ visit sign_in_path
+
+ @user.update!(webauthn_id: "a")
+
+ click_on "Authenticate with security device"
+
+ refute page.has_content? "Dashboard"
+ assert page.has_content? "Sign in"
+ end
+
+ test "sign in with webauthn does not expire" do
+ visit sign_in_path
+
+ travel 30.minutes do
+ click_on "Authenticate with security device"
+
+ assert page.has_content? "Dashboard"
+ end
+ end
end
diff --git a/test/system/webauthn_verification_test.rb b/test/system/webauthn_verification_test.rb
index 979e37ba43a..ea559350d10 100644
--- a/test/system/webauthn_verification_test.rb
+++ b/test/system/webauthn_verification_test.rb
@@ -11,7 +11,6 @@ class WebAuthnVerificationTest < ApplicationSystemTestCase
test "when verifying webauthn credential" do
visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port })
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
assert_match "Authenticate with Security Device", page.html
assert_match "Authenticating as #{@user.handle}", page.html
@@ -28,7 +27,6 @@ class WebAuthnVerificationTest < ApplicationSystemTestCase
test "when verifying webauthn credential on safari" do
assert_poll_status("pending")
visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port })
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
assert_match "Authenticate with Security Device", page.html
assert_match "Authenticating as #{@user.handle}", page.html
@@ -47,7 +45,6 @@ class WebAuthnVerificationTest < ApplicationSystemTestCase
test "when client closes connection during verification" do
visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port })
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
assert_match "Authenticate with Security Device", page.html
assert_match "Authenticating as #{@user.handle}", page.html
@@ -66,7 +63,6 @@ class WebAuthnVerificationTest < ApplicationSystemTestCase
test "when port given does not match the client port" do
wrong_port = 1111
visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: wrong_port })
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
assert_match "Authenticate with Security Device", page.html
assert_match "Authenticating as #{@user.handle}", page.html
@@ -84,7 +80,6 @@ class WebAuthnVerificationTest < ApplicationSystemTestCase
test "when there is a client error" do
@mock_client.response = @mock_client.bad_request_response
visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port })
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
assert_match "Authenticate with Security Device", page.html
assert_match "Authenticating as #{@user.handle}", page.html
@@ -100,7 +95,6 @@ class WebAuthnVerificationTest < ApplicationSystemTestCase
test "when webauthn verification is expired during verification" do
visit webauthn_verification_path(webauthn_token: @verification.path_token, params: { port: @port })
- WebAuthn::AuthenticatorAssertionResponse.any_instance.stubs(:verify).returns true
travel 3.minutes do
assert_match "Authenticate with Security Device", page.html
@@ -117,7 +111,9 @@ class WebAuthnVerificationTest < ApplicationSystemTestCase
def teardown
@mock_client.kill_server
- @authenticator.remove!
+ @authenticator&.remove!
+ Capybara.reset_sessions!
+ Capybara.use_default_driver
end
private
@@ -129,7 +125,7 @@ def assert_link_is_expired
end
def assert_poll_status(status)
- @api_key ||= create(:api_key, key: "12345", push_rubygem: true, user: @user)
+ @api_key ||= create(:api_key, key: "12345", push_rubygem: true, owner: @user)
Capybara.current_driver = :rack_test
page.driver.header "AUTHORIZATION", "12345"
diff --git a/test/tasks/maintenance/upload_info_files_to_s3_task_test.rb b/test/tasks/maintenance/upload_info_files_to_s3_task_test.rb
new file mode 100644
index 00000000000..dbdc1559b8e
--- /dev/null
+++ b/test/tasks/maintenance/upload_info_files_to_s3_task_test.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class Maintenance::UploadInfoFilesToS3TaskTest < ActiveSupport::TestCase
+ include ActiveJob::TestHelper
+
+ test "#process performs a task iteration" do
+ rubygem = create(:rubygem)
+ assert_enqueued_jobs 1, only: UploadInfoFileJob do
+ assert_enqueued_with(job: UploadInfoFileJob, args: [{ rubygem_name: rubygem.name }]) do
+ Maintenance::UploadInfoFilesToS3Task.process(rubygem)
+ end
+ end
+ end
+
+ test "#collection returns the elements to process" do
+ create(:rubygem)
+ rubygem = create(:rubygem)
+ create(:version, rubygem: rubygem)
+
+ assert_same_elements [rubygem], Maintenance::UploadInfoFilesToS3Task.collection
+ end
+end
diff --git a/test/tasks/maintenance/verify_gem_contents_in_fs_task_test.rb b/test/tasks/maintenance/verify_gem_contents_in_fs_task_test.rb
index 71fa67ea6df..a4121ee024f 100644
--- a/test/tasks/maintenance/verify_gem_contents_in_fs_task_test.rb
+++ b/test/tasks/maintenance/verify_gem_contents_in_fs_task_test.rb
@@ -31,16 +31,18 @@ def task(**attrs)
assert_semantic_logger_event(
@task.logger.events[1],
level: :warn,
- message_includes: ".gemspec.rz is missing"
+ message_includes: "is missing spec contents"
)
end
should "not error when checksums match" do
gem = "foo-1.0.0.gem"
+ gemspec = "#{gem}spec"
sha256 = Digest::SHA256.base64digest(gem)
- version = create(:version, sha256:)
+ spec_sha256 = Digest::SHA256.base64digest(gemspec)
+ version = create(:version, sha256:, spec_sha256:)
RubygemFs.instance.store("gems/#{version.full_name}.gem", gem)
- RubygemFs.instance.store("quick/Marshal.4.8/#{version.full_name}.gemspec.rz", "")
+ RubygemFs.instance.store("quick/Marshal.4.8/#{version.full_name}.gemspec.rz", gemspec)
@task = task
@task.process(version)
@@ -49,19 +51,24 @@ def task(**attrs)
end
should "error when checksums do not match" do
- version = create(:version, sha256: "abcd")
+ version = create(:version, sha256: "abcd", spec_sha256: "defg")
RubygemFs.instance.store("gems/#{version.full_name}.gem", "abcd")
- RubygemFs.instance.store("quick/Marshal.4.8/#{version.full_name}.gemspec.rz", "")
+ RubygemFs.instance.store("quick/Marshal.4.8/#{version.full_name}.gemspec.rz", "defg")
@task = task
@task.process(version)
- assert_equal 1, @task.logger.events.size
+ assert_equal 2, @task.logger.events.size
assert_semantic_logger_event(
@task.logger.events[0],
level: :error,
message_includes: "has incorrect checksum (expected abcd, got #{Digest::SHA256.base64digest('abcd')})"
)
+ assert_semantic_logger_event(
+ @task.logger.events[1],
+ level: :error,
+ message_includes: "has incorrect checksum (expected defg, got #{Digest::SHA256.base64digest('defg')})"
+ )
end
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 7ebe992745a..561e63bff5a 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -28,7 +28,8 @@
require "capybara/minitest"
require "clearance/test_unit"
require "webauthn/fake_client"
-require "shoulda"
+require "shoulda/context"
+require "shoulda/matchers"
require "helpers/admin_helpers"
require "helpers/gem_helpers"
require "helpers/email_helpers"
@@ -149,9 +150,12 @@ def create_webauthn_credential
click_button "Sign in"
visit edit_settings_path
- options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new
+ options = ::Selenium::WebDriver::VirtualAuthenticatorOptions.new(
+ resident_key: true,
+ user_verification: true,
+ user_verified: true
+ )
@authenticator = page.driver.browser.add_virtual_authenticator(options)
- WebAuthn::PublicKeyCredentialWithAttestation.any_instance.stubs(:verify).returns true
credential_nickname = "new cred"
fill_in "Nickname", with: credential_nickname
@@ -179,14 +183,18 @@ class ActionDispatch::IntegrationTest
Gemcutter::Application.load_tasks
+# Force loading of ActionDispatch::SystemTesting::* helpers
+_ = ActionDispatch::SystemTestCase
+
class SystemTest < ActionDispatch::IntegrationTest
include Capybara::DSL
+ include Capybara::Minitest::Assertions
+ include ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelper
+ include ActionDispatch::SystemTesting::TestHelpers::SetupAndTeardown
setup do
Capybara.current_driver = :rack_test
end
-
- teardown { reset_session! }
end
Shoulda::Matchers.configure do |config|
diff --git a/test/unit/helpers/api_keys_helper_test.rb b/test/unit/helpers/api_keys_helper_test.rb
index 956ca2db665..e3ff6db5cad 100644
--- a/test/unit/helpers/api_keys_helper_test.rb
+++ b/test/unit/helpers/api_keys_helper_test.rb
@@ -4,7 +4,7 @@ class ApiKeysHelperTest < ActionView::TestCase
context "gem_scope" do
should "return gem name" do
@ownership = create(:ownership)
- @api_key = create(:api_key, push_rubygem: true, user: @ownership.user, ownership: @ownership)
+ @api_key = create(:api_key, push_rubygem: true, owner: @ownership.user, ownership: @ownership)
assert_equal @ownership.rubygem.name, gem_scope(@api_key)
end
@@ -15,7 +15,7 @@ class ApiKeysHelperTest < ActionView::TestCase
should "return error tooltip if key if gem ownership is removed" do
@ownership = create(:ownership)
- @api_key = create(:api_key, push_rubygem: true, user: @ownership.user, ownership: @ownership)
+ @api_key = create(:api_key, push_rubygem: true, owner: @ownership.user, ownership: @ownership)
@ownership.destroy!
rubygem_name = @ownership.rubygem.name