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 %> +
    + + + <%= submit_tag t("multifactor_auths.prompt.sign_in_with_webauthn_credential"), class: 'js-webauthn-session--submit form__submit form__submit--no-hover' %> +
    + <% 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 %> -
    - - - <%= submit_tag t(".sign_in_with_webauthn_credential"), class: 'js-webauthn-session--submit form__submit form__submit--no-hover' %> -
    - <% 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