diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 99333cccda3..c8f990c20a6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - oidc-api-tokens permissions: contents: read id-token: write diff --git a/Gemfile b/Gemfile index 36e18e61e8d..3aa77627d1e 100644 --- a/Gemfile +++ b/Gemfile @@ -25,6 +25,7 @@ gem "octokit", "~> 6.1" gem "omniauth-github", "~> 2.0" gem "omniauth", "~> 2.1" gem "omniauth-rails_csrf_protection", "~> 1.0" +gem "openid_connect", "~> 1.4" gem "pg", "~> 1.4" gem "puma", "~> 6.1" gem "rack", "~> 2.2" diff --git a/Gemfile.lock b/Gemfile.lock index f26cf2c9542..a22ca1f329c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -71,6 +71,7 @@ GEM tzinfo (~> 2.0) addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) + aes_key_wrap (1.1.0) aggregate_assertions (0.2.0) minitest (~> 5.0) amazing_print (1.5.0) @@ -79,6 +80,7 @@ GEM ffi (~> 1.14) ffi-compiler (~> 1.0) ast (2.4.2) + attr_required (1.0.1) autoprefixer-rails (10.4.13.0) execjs (~> 2) avo (2.35.0) @@ -250,6 +252,7 @@ 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) @@ -263,6 +266,11 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.6.3) + json-jwt (1.15.3) + activesupport (>= 4.2) + aes_key_wrap + bindata + httpclient jwt (2.7.0) kaminari (1.2.2) activesupport (>= 4.1.0) @@ -370,6 +378,17 @@ GEM omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) omniauth (~> 2.0) + openid_connect (1.4.2) + activemodel + attr_required (>= 1.0.0) + json-jwt (>= 1.15.0) + net-smtp + rack-oauth2 (~> 1.21) + swd (~> 1.3) + tzinfo + validate_email + validate_url + webfinger (~> 1.2) opensearch-api (1.0.0) multi_json opensearch-dsl (0.2.1) @@ -407,6 +426,12 @@ GEM rack (2.2.8) rack-attack (6.7.0) rack (>= 1.0, < 4) + rack-oauth2 (1.21.3) + activesupport + attr_required + httpclient + json-jwt (>= 1.11.0) + rack (>= 2.1.0) rack-protection (3.0.5) rack rack-test (2.1.0) @@ -565,6 +590,10 @@ GEM sprockets (>= 3.0.0) statsd-instrument (3.5.11) stringio (3.0.2) + swd (1.3.0) + activesupport (>= 3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) terser (1.1.17) execjs (>= 0.3.0, < 3) thor (1.2.2) @@ -588,6 +617,12 @@ GEM 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) @@ -604,6 +639,9 @@ GEM openssl (>= 2.2) safety_net_attestation (~> 0.4.0) tpm-key_attestation (~> 0.12.0) + webfinger (1.2.0) + activesupport + httpclient (>= 2.4) webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -667,6 +705,7 @@ DEPENDENCIES 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) pg (~> 1.4) diff --git a/app/avo/actions/base_action.rb b/app/avo/actions/base_action.rb index 95edc922077..c0d2d55ed02 100644 --- a/app/avo/actions/base_action.rb +++ b/app/avo/actions/base_action.rb @@ -1,4 +1,6 @@ class BaseAction < Avo::BaseAction + include SemanticLogger::Loggable + field :comment, as: :textarea, required: true, help: "A comment explaining why this action was taken.
Will be saved in the audit log.
Must be more than 10 characters." @@ -46,7 +48,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists attr_reader :models, :fields, :current_user, :arguments, :resource - delegate :error, :avo, :keep_modal_open, :redirect_to, :inform, :action_name, :succeed, + delegate :error, :avo, :keep_modal_open, :redirect_to, :inform, :action_name, :succeed, :logger, to: :@action set_callback :handle, :before do diff --git a/app/avo/actions/refresh_oidc_provider.rb b/app/avo/actions/refresh_oidc_provider.rb new file mode 100644 index 00000000000..64769d51d1a --- /dev/null +++ b/app/avo/actions/refresh_oidc_provider.rb @@ -0,0 +1,29 @@ +class RefreshOIDCProvider < BaseAction + self.name = "Refresh OIDC Provider" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.message = lambda { + "Are you sure you would like to refresh #{record.issuer}?" + } + + self.confirm_button_label = "Refresh" + + class ActionHandler < ActionHandler + def handle_model(provider) + connection = Faraday.new(provider.issuer, request: { timeout: 2 }) do |f| + f.request :json + f.response :logger, logger, headers: false, errors: true, bodies: true + f.response :raise_error + f.response :json + end + resp = connection.get("/.well-known/openid-configuration") + + provider.configuration = resp.body + provider.jwks = connection.get(provider.configuration.jwks_uri).body + + provider.save! + end + end +end diff --git a/app/avo/fields/array_of_field.rb b/app/avo/fields/array_of_field.rb new file mode 100644 index 00000000000..e798c45244b --- /dev/null +++ b/app/avo/fields/array_of_field.rb @@ -0,0 +1,38 @@ +class ArrayOfField < Avo::Fields::BaseField + def initialize(name, field:, field_options: {}, **args, &block) + super(name, **args, &nil) + + @make_field = lambda do |id:, index: nil, value: nil| + items_holder = Avo::ItemsHolder.new + items_holder.field(id, name: index&.to_s || self.name, as: field, required: -> { false }, value:, **field_options, &block) + items_holder.items.sole.hydrate(view:, resource:) + end + end + + def value(...) + value = super(...) + Array.wrap(value) + end + + def template_member + @make_field[id: "#{id}[NEW_RECORD]"] + end + + def fill_field(model, key, value, params) + value = value.each_value.map do |v| + template_member.fill_field(NestedField::Holder.new, :item, v, params).item + end + super(model, key, value, params) + end + + def members + value.each_with_index.map do |value, idx| + id = "#{self.id}[#{idx}]" + @make_field[id:, index: idx, value:] + end + end + + def to_permitted_param + @make_field[id:].to_permitted_param + end +end diff --git a/app/avo/fields/json_viewer_field.rb b/app/avo/fields/json_viewer_field.rb index 8c0e7522fc6..df4d52e45d3 100644 --- a/app/avo/fields/json_viewer_field.rb +++ b/app/avo/fields/json_viewer_field.rb @@ -1,12 +1,9 @@ -class JsonViewerField < Avo::Fields::BaseField +class JsonViewerField < Avo::Fields::CodeField def initialize(name, **args, &) - super(name, **args, &) - @theme = args[:theme].present? ? args[:theme].to_s : "default" - @height = args[:height].present? ? args[:height].to_s : "auto" - @tab_size = args[:tab_size].presence || 2 - @indent_with_tabs = args[:indent_with_tabs].presence || false - @line_wrapping = args[:line_wrapping].presence || true + super(name, **args, language: :javascript, line_wrapping: true, &) end - attr_reader :height, :theme, :tab_size, :indent_with_tabs, :line_wrapping + def value(...) + super&.then { JSON.pretty_generate(_1.as_json) } + end end diff --git a/app/avo/fields/nested_field.rb b/app/avo/fields/nested_field.rb new file mode 100644 index 00000000000..962c930db4f --- /dev/null +++ b/app/avo/fields/nested_field.rb @@ -0,0 +1,34 @@ +class NestedField < Avo::Fields::BaseField + include Avo::Concerns::HasFields + + def initialize(name, stacked: true, **args, &block) + @items_holder = Avo::ItemsHolder.new + hide_on [:index] + super(name, stacked:, **args, &nil) + instance_exec(&block) if block + end + + def fields(**_kwargs) + @items_holder.items.grep Avo::Fields::BaseField + end + + def field(name, **kwargs, &) + @items_holder.field(name, **kwargs, &) + end + + def fill_field(model, key, value, params) + value = value.to_h.to_h do |k, v| + [k, get_field(k).fill_field(Holder.new, :item, v, params).item] + end + + super(model, key, value, params) + end + + def to_permitted_param + { super => fields.map(&:to_permitted_param) } + end + + class Holder + attr_accessor :item + end +end diff --git a/app/avo/resources/api_key_resource.rb b/app/avo/resources/api_key_resource.rb index f58e4d19a45..d54462c3413 100644 --- a/app/avo/resources/api_key_resource.rb +++ b/app/avo/resources/api_key_resource.rb @@ -2,6 +2,9 @@ class ApiKeyResource < Avo::BaseResource self.title = :name self.includes = [] + class ExpiredFilter < ScopeBooleanFilter; end + filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } } + field :id, as: :id, hide_on: :index field :name, as: :text, link_to_resource: true @@ -10,6 +13,7 @@ class ApiKeyResource < Avo::BaseResource field :last_accessed_at, as: :date_time field :soft_deleted_at, as: :date_time field :soft_deleted_rubygem_name, as: :text + field :expires_at, as: :date_time field :enabled_scopes, as: :tags @@ -28,4 +32,5 @@ class ApiKeyResource < Avo::BaseResource field :api_key_rubygem_scope, as: :has_one field :ownership, as: :has_one + field :oidc_id_token, as: :has_one end diff --git a/app/avo/resources/oidc_api_key_role_resource.rb b/app/avo/resources/oidc_api_key_role_resource.rb new file mode 100644 index 00000000000..ea7059b08f0 --- /dev/null +++ b/app/avo/resources/oidc_api_key_role_resource.rb @@ -0,0 +1,36 @@ +class OIDCApiKeyRoleResource < Avo::BaseResource + self.title = :token + self.includes = [] + self.model_class = ::OIDC::ApiKeyRole + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + field :token, as: :text, link_to_resource: true, readonly: true + field :id, as: :id, link_to_resource: true, hide_on: :index + # Fields generated from the model + field :name, as: :text + field :provider, as: :belongs_to + field :user, as: :belongs_to + field :api_key_permissions, as: :nested do + field :valid_for, as: :text, format_using: :iso8601 + field :scopes, as: :tags, suggestions: ApiKey::API_SCOPES.map { { label: _1, value: _1 } } + field :gems, as: :tags, suggestions: -> { Rubygem.limit(10).pluck(:name).map { { value: _1, label: _1 } } } + end + field :access_policy, as: :nested do + field :statements, as: :array_of, field: :nested do + field :effect, as: :select, options: { "Allow" => "allow" }, default: "Allow" + field :principal, as: :nested, field_options: { stacked: false } do + field :oidc, as: :text + end + field :conditions, as: :array_of, field: :nested, field_options: { stacked: false } do + field :operator, as: :select, options: OIDC::AccessPolicy::Statement::Condition::OPERATORS.index_by(&:titleize) + field :claim, as: :text + field :value, as: :text + end + end + end + + field :id_tokens, as: :has_many + # add fields here +end diff --git a/app/avo/resources/oidc_id_token_resource.rb b/app/avo/resources/oidc_id_token_resource.rb new file mode 100644 index 00000000000..bda90ed7025 --- /dev/null +++ b/app/avo/resources/oidc_id_token_resource.rb @@ -0,0 +1,23 @@ +class OIDCIdTokenResource < Avo::BaseResource + self.title = :id + self.includes = [] + self.model_class = ::OIDC::IdToken + # self.search_query = -> do + # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + field :id, as: :id + # Fields generated from the model + field :api_key_role, as: :belongs_to + field :provider, as: :has_one + field :api_key, as: :has_one + + heading "JWT" + field :claims, as: :key_value, stacked: true do + model.jwt.fetch("claims") + end + field :header, as: :key_value, stacked: true do + model.jwt.fetch("header") + end + # add fields here +end diff --git a/app/avo/resources/oidc_provider_resource.rb b/app/avo/resources/oidc_provider_resource.rb new file mode 100644 index 00000000000..7f209c2c895 --- /dev/null +++ b/app/avo/resources/oidc_provider_resource.rb @@ -0,0 +1,23 @@ +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 + + # Fields generated from the model + field :issuer, as: :text, link_to_resource: true + field :configuration, as: :nested do + visible_on = %i[edit new] + OIDC::Provider::Configuration.then { (_1.required_attributes + _1.optional_attributes) - fields.map(&:id) }.each do |k| + field k, as: (k.to_s.end_with?("s_supported") ? :tags : :text), visible: ->(_) { visible_on.include?(view) || value.send(k).present? } + end + end + field :jwks, as: :array_of, field: :json_viewer, hide_on: :index + field :api_key_roles, as: :has_many + # add fields here + field :id, as: :id +end diff --git a/app/avo/resources/version_resource.rb b/app/avo/resources/version_resource.rb index 694c4268571..3dceab35c19 100644 --- a/app/avo/resources/version_resource.rb +++ b/app/avo/resources/version_resource.rb @@ -28,6 +28,7 @@ class IndexedFilter < ScopeBooleanFilter; end field :yanked_at, as: :date_time, sortable: true field :pusher, as: :belongs_to, class: "User" + field :pusher_api_key, as: :belongs_to, class: "ApiKey" tabs do tab "Metadata", description: "Metadata that comes from the gemspec" do diff --git a/app/components/avo/audited_changes_record_diff/show_component.rb b/app/components/avo/audited_changes_record_diff/show_component.rb index eb5e060da67..15e30b6ca2a 100644 --- a/app/components/avo/audited_changes_record_diff/show_component.rb +++ b/app/components/avo/audited_changes_record_diff/show_component.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true class Avo::AuditedChangesRecordDiff::ShowComponent < ViewComponent::Base - def initialize(gid:, changes:, unchanged:, user:) + def initialize(gid:, changes:, unchanged:, view:, user:) super @gid = gid @changes = changes @unchanged = unchanged @user = user + @view = view global_id = GlobalID.parse(gid) model = begin @@ -14,18 +15,18 @@ def initialize(gid:, changes:, unchanged:, user:) rescue ActiveRecord::RecordNotFound global_id.model_class.new(id: global_id.model_id) end - return unless (@resource = Avo::App.get_resource_by_model_name(global_id.model_class.name)) - @resource.hydrate(model:, user:) + return unless (@resource = Avo::App.get_resource_by_model_name(global_id.model_class)) + @resource.hydrate(model:, user:, view:) - @old_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:first))) - @new_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:last))) + @old_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:first)), view:) + @new_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:last)), view:) end def render? @resource.present? end - attr_reader :gid, :changes, :unchanged, :user, :resource, :old_resource, :new_resource + attr_reader :gid, :changes, :unchanged, :user, :resource, :old_resource, :new_resource, :view def sorted_fields @resource.fields @@ -58,8 +59,8 @@ def each_field # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedC end def component_for_field(field, resource) - field = field.hydrate(model: resource.model) - field.component_for_view(:show).new(field:, resource:) + field = field.hydrate(model: resource.model, view:) + field.component_for_view(view).new(field:, resource:) end def authorized? diff --git a/app/components/avo/fields/array_of_field/edit_component.html.erb b/app/components/avo/fields/array_of_field/edit_component.html.erb new file mode 100644 index 00000000000..cd96cb09ac7 --- /dev/null +++ b/app/components/avo/fields/array_of_field/edit_component.html.erb @@ -0,0 +1,31 @@ +<%= field_wrapper **field_wrapper_args, stacked: true, data: {} do %> + <%= content_tag :div, data: { controller: 'nested-form', nested_form_wrapper_selector_value: '.nested-form-wrapper' } do %> + + + <% field.members.each do |f| %> +
+ <%= a_link 'javascript:void(0);', icon: 'trash', color: :red, style: :text, data: {action: "click->nested-form#remove"} do %> + Remove <%= @field.name.singularize %> + <% end %> + <%= render f.component_for_view(view).new(field: f, form:, view:) %> + <% if field.model && field.model.errors.include?(f.id) %> +
<%= field.model.errors.messages_for(f.id).to_sentence %>
+ <% end %> +
+ <% end %> + +
+ + + <%= a_link 'javascript:void(0);', icon: 'plus', color: :primary, style: :outline, data: {action: "click->nested-form#add"} do %> + Add another <%= @field.name.singularize %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/avo/fields/array_of_field/edit_component.rb b/app/components/avo/fields/array_of_field/edit_component.rb new file mode 100644 index 00000000000..f933f72d47b --- /dev/null +++ b/app/components/avo/fields/array_of_field/edit_component.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Avo::Fields::ArrayOfField::EditComponent < Avo::Fields::EditComponent + include Avo::ApplicationHelper +end diff --git a/app/components/avo/fields/array_of_field/show_component.html.erb b/app/components/avo/fields/array_of_field/show_component.html.erb new file mode 100644 index 00000000000..296f7245b15 --- /dev/null +++ b/app/components/avo/fields/array_of_field/show_component.html.erb @@ -0,0 +1,7 @@ +<%= field_wrapper **field_wrapper_args, stacked: true, data: {} do %> +
+ <% field.members.each do |f| %> + <%= render f.component_for_view(view).new(field: f) %> + <% end %> +
+<% end %> diff --git a/app/components/avo/fields/array_of_field/show_component.rb b/app/components/avo/fields/array_of_field/show_component.rb new file mode 100644 index 00000000000..c24203cfa51 --- /dev/null +++ b/app/components/avo/fields/array_of_field/show_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::ArrayOfField::ShowComponent < Avo::Fields::ShowComponent +end diff --git a/app/components/avo/fields/audited_changes_field/show_component.html.erb b/app/components/avo/fields/audited_changes_field/show_component.html.erb index 26f93955203..9b4ee42a56e 100644 --- a/app/components/avo/fields/audited_changes_field/show_component.html.erb +++ b/app/components/avo/fields/audited_changes_field/show_component.html.erb @@ -4,6 +4,7 @@ gid:, changes:, unchanged:, + view:, user: resource.user, ) %> <% end %> diff --git a/app/components/avo/fields/json_viewer_field/edit_component.rb b/app/components/avo/fields/json_viewer_field/edit_component.rb new file mode 100644 index 00000000000..2c59666166f --- /dev/null +++ b/app/components/avo/fields/json_viewer_field/edit_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::JsonViewerField::EditComponent < Avo::Fields::CodeField::EditComponent +end diff --git a/app/components/avo/fields/json_viewer_field/show_component.html.erb b/app/components/avo/fields/json_viewer_field/show_component.html.erb deleted file mode 100644 index d9de78adeea..00000000000 --- a/app/components/avo/fields/json_viewer_field/show_component.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<%= field_wrapper **field_wrapper_args, full_width: true do %> -
- <%= text_area_tag @field.id, pretty_json, - class: helpers.input_classes('w-full'), - placeholder: @field.placeholder, - disabled: true, - data: { - 'code-field-target': 'element', - view: view, - language: :javascript, - theme: @field.theme, - 'tab-size': @field.tab_size, - 'read-only': true, - 'indent-with-tabs': @field.indent_with_tabs, - 'line-wrapping': @field.line_wrapping, - } - %> -
-<% end %> \ No newline at end of file diff --git a/app/components/avo/fields/json_viewer_field/show_component.rb b/app/components/avo/fields/json_viewer_field/show_component.rb index e8c666e703a..18638f1be55 100644 --- a/app/components/avo/fields/json_viewer_field/show_component.rb +++ b/app/components/avo/fields/json_viewer_field/show_component.rb @@ -1,7 +1,4 @@ # frozen_string_literal: true -class Avo::Fields::JsonViewerField::ShowComponent < Avo::Fields::ShowComponent - def pretty_json - JSON.pretty_generate(@field.value) - end +class Avo::Fields::JsonViewerField::ShowComponent < Avo::Fields::CodeField::ShowComponent end diff --git a/app/components/avo/fields/nested_field/edit_component.html.erb b/app/components/avo/fields/nested_field/edit_component.html.erb new file mode 100644 index 00000000000..f4a7c9628fe --- /dev/null +++ b/app/components/avo/fields/nested_field/edit_component.html.erb @@ -0,0 +1,21 @@ +<%= field_wrapper **field_wrapper_args, class: "nested-form-wrapper", data: {} do %> + <% form.fields_for field.id, field.value do |form| %> + <% field.get_fields.each do |f| %> + <%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f, form:, view:) %> + <% if field.value && field.value.errors.include?(f.id) %> +
<%= field.value.errors.messages_for(f.id).to_sentence %>
+ <% end %> + <% end %> + <% end %> + <% if field.value && field.value.errors.include?(:base) %> +
+ <% field.value.errors.messages_for(:base).each do |attr, messages| %> +
+ <% end %> +<% end %> diff --git a/app/components/avo/fields/nested_field/edit_component.rb b/app/components/avo/fields/nested_field/edit_component.rb new file mode 100644 index 00000000000..6495adb5b70 --- /dev/null +++ b/app/components/avo/fields/nested_field/edit_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::NestedField::EditComponent < Avo::Fields::EditComponent +end diff --git a/app/components/avo/fields/nested_field/show_component.html.erb b/app/components/avo/fields/nested_field/show_component.html.erb new file mode 100644 index 00000000000..db319a44a4a --- /dev/null +++ b/app/components/avo/fields/nested_field/show_component.html.erb @@ -0,0 +1,7 @@ +<%= field_wrapper **field_wrapper_args, data: {} do %> +
+ <% field.get_fields.each do |f| %> + <%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f) %> + <% end %> +
+<% end %> diff --git a/app/components/avo/fields/nested_field/show_component.rb b/app/components/avo/fields/nested_field/show_component.rb new file mode 100644 index 00000000000..039c539f9cf --- /dev/null +++ b/app/components/avo/fields/nested_field/show_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Avo::Fields::NestedField::ShowComponent < Avo::Fields::ShowComponent +end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index db60595fd7f..69013864306 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -96,7 +96,7 @@ def render_mfa_strong_level_required_error def authenticate_with_api_key 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) return render_unauthorized unless @api_key set_tags "gemcutter.user.id" => @api_key.user_id, "gemcutter.user.api_key_id" => @api_key.id render_soft_deleted_api_key if @api_key.soft_deleted? diff --git a/app/controllers/api/v1/oidc/api_key_roles_controller.rb b/app/controllers/api/v1/oidc/api_key_roles_controller.rb new file mode 100644 index 00000000000..174efd3a150 --- /dev/null +++ b/app/controllers/api/v1/oidc/api_key_roles_controller.rb @@ -0,0 +1,85 @@ +class Api::V1::OIDC::ApiKeyRolesController < Api::BaseController + include ApiKeyable + + before_action :authenticate_with_api_key, except: :assume_role + + with_options only: :assume_role do + before_action :set_api_key_role + before_action :decode_jwt + before_action :verify_jwt + before_action :verify_access + end + + class UnverifiedJWT < StandardError + end + + rescue_from( + UnverifiedJWT, + JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound, + OIDC::AccessPolicy::AccessError, + with: :render_not_found + ) + + rescue_from ActiveRecord::RecordInvalid do |err| + render json: { + errors: err.record.errors + }, status: :unprocessable_entity + end + + def index + render json: @api_key.user.oidc_api_key_roles + end + + def show + render json: @api_key.user.oidc_api_key_roles.find_by!(token: params.require(:token)) + end + + def assume_role + key = nil + api_key = nil + ApiKey.transaction do + key = generate_unique_rubygems_key + api_key = @api_key_role.user.api_keys.create!( + hashed_key: hashed_key(key), + name: "#{@api_key_role.name}-#{@jwt[:jti]}", + **@api_key_role.api_key_permissions.create_params(@api_key_role.user) + ) + OIDC::IdToken.create!( + api_key:, + jwt: { claims: @jwt, header: @jwt.header }, + api_key_role: @api_key_role, + provider: @api_key_role.provider + ) + Mailer.api_key_created(api_key.id).deliver_later + end + + 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 set_api_key_role + @api_key_role = OIDC::ApiKeyRole.find_by!(token: params.require(:token)) + end + + def decode_jwt + @jwt = JSON::JWT.decode_compact_serialized(params.require(:jwt), @api_key_role.provider.jwks) + rescue JSON::ParserError + raise UnverifiedJWT, "Invalid JSON" + end + + def verify_jwt + raise UnverifiedJWT, "Issuer mismatch" unless @api_key_role.provider.issuer == @jwt["iss"] + raise UnverifiedJWT, "Invalid time" unless (@jwt["nbf"]..@jwt["exp"]).cover?(Time.now.to_i) + end + + def verify_access + @api_key_role.access_policy.verify_access!(@jwt) + end +end diff --git a/app/controllers/api/v1/oidc/id_tokens_controller.rb b/app/controllers/api/v1/oidc/id_tokens_controller.rb new file mode 100644 index 00000000000..70e6aa6be9b --- /dev/null +++ b/app/controllers/api/v1/oidc/id_tokens_controller.rb @@ -0,0 +1,11 @@ +class Api::V1::OIDC::IdTokensController < Api::BaseController + before_action :authenticate_with_api_key + + def index + render json: @api_key.user.oidc_id_tokens + end + + def show + render json: @api_key.user.oidc_id_tokens.find(params.require(:id)) + end +end diff --git a/app/controllers/api/v1/oidc/providers_controller.rb b/app/controllers/api/v1/oidc/providers_controller.rb new file mode 100644 index 00000000000..bb285adfd02 --- /dev/null +++ b/app/controllers/api/v1/oidc/providers_controller.rb @@ -0,0 +1,11 @@ +class Api::V1::OIDC::ProvidersController < Api::BaseController + before_action :authenticate_with_api_key + + def index + render json: OIDC::Provider.all + end + + def show + render json: OIDC::Provider.find(params.require(:id)) + end +end diff --git a/app/controllers/api/v1/rubygems_controller.rb b/app/controllers/api/v1/rubygems_controller.rb index 62292a9cd1e..42f85e7319c 100644 --- a/app/controllers/api/v1/rubygems_controller.rb +++ b/app/controllers/api/v1/rubygems_controller.rb @@ -33,7 +33,7 @@ def show def create return render_api_key_forbidden unless @api_key.can_push_rubygem? - gemcutter = Pusher.new(@api_key.user, request.body, request.remote_ip, @api_key.rubygem) + gemcutter = Pusher.new(@api_key, request.body, request.remote_ip) enqueue_web_hook_jobs(gemcutter.version) if gemcutter.process render plain: response_with_mfa_warning(gemcutter.message), status: gemcutter.code rescue StandardError => e diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb index cbec0a5be70..205c385c90f 100644 --- a/app/controllers/api_keys_controller.rb +++ b/app/controllers/api_keys_controller.rb @@ -7,7 +7,7 @@ class ApiKeysController < ApplicationController def index @api_key = session.delete(:api_key) - @api_keys = current_user.api_keys + @api_keys = current_user.api_keys.unexpired redirect_to new_profile_api_key_path if @api_keys.empty? end @@ -65,7 +65,7 @@ def update def destroy api_key = current_user.api_keys.find(params.require(:id)) - if api_key.destroy + if api_key.expire! flash[:notice] = t(".success", name: api_key.name) else flash[:error] = api_key.errors.full_messages.to_sentence @@ -74,7 +74,7 @@ def destroy end def reset - if current_user.api_keys.destroy_all + if current_user.api_keys.expire_all! flash[:notice] = t(".success") else flash[:error] = t("try_again") diff --git a/app/controllers/avo/oidc_api_key_roles_controller.rb b/app/controllers/avo/oidc_api_key_roles_controller.rb new file mode 100644 index 00000000000..03b51cf50b8 --- /dev/null +++ b/app/controllers/avo/oidc_api_key_roles_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::OIDCApiKeyRolesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_id_tokens_controller.rb b/app/controllers/avo/oidc_id_tokens_controller.rb new file mode 100644 index 00000000000..cd5c87356d3 --- /dev/null +++ b/app/controllers/avo/oidc_id_tokens_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::OIDCIdTokensController < Avo::ResourcesController +end diff --git a/app/controllers/avo/oidc_providers_controller.rb b/app/controllers/avo/oidc_providers_controller.rb new file mode 100644 index 00000000000..2e20964d8aa --- /dev/null +++ b/app/controllers/avo/oidc_providers_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::OIDCProvidersController < Avo::ResourcesController +end diff --git a/app/controllers/concerns/avo_auditable.rb b/app/controllers/concerns/avo_auditable.rb index aaabd6667c4..3a4ce7313eb 100644 --- a/app/controllers/concerns/avo_auditable.rb +++ b/app/controllers/concerns/avo_auditable.rb @@ -8,7 +8,6 @@ module AvoAuditable def perform_action_and_record_errors(&) super do action = params.fetch(:action) - logger.error(permitted_params:) fields = action == "destroy" ? {} : cast_nullable(model_params) @model.errors.add :comment, "must supply a sufficiently detailed comment" if fields[:comment]&.then { _1.length < 10 } diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index 5821533a91b..c026ce7890a 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -23,7 +23,7 @@ def show def authenticate_with_api_key params_key = request.headers["Authorization"] || params.permit(:api_key).fetch(:api_key, "") 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) end def api_or_logged_in_user diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index f9e91753829..e7a4ef33713 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -23,7 +23,7 @@ def update if @user.update_password password_from_password_reset_params @user.reset_api_key! if reset_params[:reset_api_key] == "true" - @user.api_keys.delete_all if reset_params[:reset_api_keys] == "true" + @user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true" sign_in @user redirect_to url_after_update session[:password_reset_token] = nil diff --git a/app/models/api_key.rb b/app/models/api_key.rb index a187729e86d..4a905ed57be 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -3,17 +3,35 @@ class ApiKey < ApplicationRecord APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner remove_owner].freeze belongs_to :user + 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_many :pushed_versions, class_name: "Version", inverse_of: :pusher_api_key, foreign_key: :pusher_api_key_id, dependent: :restrict_with_error + validates :user, :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 } validate :rubygem_scope_definition, if: :ownership validate :not_soft_deleted? + validate :not_expired? delegate :rubygem_id, :rubygem, to: :ownership, allow_nil: true + 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 :oidc, -> { joins(:oidc_id_token) } + scope :not_oidc, -> { where.missing(:oidc_id_token) } + + def self.expire_all! + transaction do + find_each.all?(&:expire!) + end + end + def enabled_scopes API_SCOPES.filter_map { |scope| scope if send(scope) } end @@ -28,6 +46,7 @@ def enabled_scopes def mfa_authorized?(otp) return true unless mfa_enabled? + return true if oidc_id_token.present? user.api_mfa_verified?(otp) end @@ -61,6 +80,14 @@ def soft_deleted_by_ownership? soft_deleted? && soft_deleted_rubygem_name.present? end + def expired? + expires_at && expires_at <= Time.now.utc + end + + def expire! + touch(:expires_at) + end + private def exclusive_show_dashboard_scope @@ -83,4 +110,9 @@ def rubygem_scope_definition def not_soft_deleted? errors.add :base, "An invalid API key cannot be used. Please delete it and create a new one." if soft_deleted? end + + 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 end diff --git a/app/models/oidc.rb b/app/models/oidc.rb new file mode 100644 index 00000000000..80e42efdbca --- /dev/null +++ b/app/models/oidc.rb @@ -0,0 +1,5 @@ +module OIDC + def self.table_name_prefix + "oidc_" + end +end diff --git a/app/models/oidc/access_policy.rb b/app/models/oidc/access_policy.rb new file mode 100644 index 00000000000..994eb6208e4 --- /dev/null +++ b/app/models/oidc/access_policy.rb @@ -0,0 +1,88 @@ +class OIDC::AccessPolicy < OIDC::BaseModel + class Statement < OIDC::BaseModel + def match_jwt?(jwt) + return unless principal.oidc == jwt[:iss] + + conditions.all? { _1.match?(jwt) } + end + + class Principal < OIDC::BaseModel + attribute :oidc, :string + + validates :oidc, presence: true + end + + class Condition < OIDC::BaseModel + def match?(jwt) + claim_value = jwt[claim] + case operator + when "string_equals" + value == claim_value + when "string_matches" + Regexp.new(value).match?(claim_value) + else + raise "Unknown operator #{operator.inspect}" + end + end + + attribute :operator, :string + attribute :claim, :string + attribute :value + + STRING_BOOLEAN_OPERATORS = %w[string_equals string_matches].freeze + + OPERATORS = STRING_BOOLEAN_OPERATORS + + validates :operator, presence: true, inclusion: { in: OPERATORS } + validates :claim, presence: true + validate :value_expected_type? + + def value_type + case operator + when *STRING_BOOLEAN_OPERATORS + String + else + NilClass + end + end + + def value_expected_type? + errors.add(:value, "must be #{value_type}") unless value.is_a?(value_type) + end + end + + EFFECTS = %w[allow deny].freeze + + attribute :effect, :string + attribute :principal, Types::JsonDeserializable.new(Principal) + attribute :conditions, Types::ArrayOf.new(Types::JsonDeserializable.new(Condition)) + + validates :effect, presence: true, inclusion: { in: EFFECTS } + + validates :principal, presence: true, nested: true + + validates :conditions, nested: true + end + + attribute :statements, Types::ArrayOf.new(Types::JsonDeserializable.new(Statement)) + + validates :statements, presence: true, nested: true + + class AccessError < StandardError + end + + def verify_access!(jwt) + matching_statements = statements.select { _1.match_jwt?(jwt) } + raise AccessError, "denying due to no matching statements" if matching_statements.empty? + + case (effect = matching_statements.last.effect) + when "allow" + # great, nothing to do. verified + nil + when "deny" + raise AccessError, "explicit denial from #{matching_statements.last.as_json}" + else + raise "Unhandled effect #{effect}" + end + end +end diff --git a/app/models/oidc/api_key_permissions.rb b/app/models/oidc/api_key_permissions.rb new file mode 100644 index 00000000000..30c5909f624 --- /dev/null +++ b/app/models/oidc/api_key_permissions.rb @@ -0,0 +1,33 @@ +class OIDC::ApiKeyPermissions < OIDC::BaseModel + def create_params(user) + params = scopes.map(&:to_sym).index_with(true) + params[:ownership] = gems&.first&.then { user.ownerships.joins(:rubygem).find_by!(rubygem: { name: _1 }) } + params[:expires_at] = DateTime.now.utc + valid_for + params + end + + attribute :scopes, Types::ArrayOf.new(:string) + attribute :valid_for, Types::Duration.new, default: -> { 30.minutes.freeze } + attribute :gems, Types::ArrayOf.new(:string) + + validates :scopes, presence: true + validate :known_scopes? + validate :scopes_must_be_unique + + validates :valid_for, presence: true, inclusion: { in: (5.minutes)..(1.day) } + + validates :gems, length: { maximum: 1 } + + def known_scopes? + scopes&.each_with_index do |scope, idx| + errors.add("scopes[#{idx}]", "unknown scope: #{scope}") unless ApiKey::API_SCOPES.include?(scope.to_sym) + end + end + + def scopes_must_be_unique + return if scopes.blank? + + errors.add(:scopes, "show_dashboard is exclusive") if scopes.include?("show_dashboard") && scopes.size > 1 + errors.add(:scopes, "must be unique") if scopes.dup.uniq! + end +end diff --git a/app/models/oidc/api_key_role.rb b/app/models/oidc/api_key_role.rb new file mode 100644 index 00000000000..97915987b84 --- /dev/null +++ b/app/models/oidc/api_key_role.rb @@ -0,0 +1,58 @@ +class OIDC::ApiKeyRole < ApplicationRecord + belongs_to :provider, class_name: "OIDC::Provider", foreign_key: "oidc_provider_id", inverse_of: :api_key_roles + belongs_to :user, inverse_of: :oidc_api_key_roles + + has_many :id_tokens, -> { order(created_at: :desc) }, + class_name: "OIDC::IdToken", inverse_of: :api_key_role, foreign_key: :oidc_api_key_role_id, dependent: :nullify + has_many :api_keys, through: :id_tokens, inverse_of: :oidc_api_key_role + + attribute :api_key_permissions, Types::JsonDeserializable.new(OIDC::ApiKeyPermissions) + validates :api_key_permissions, presence: true, nested: true + validate :gems_belong_to_user + + def gems_belong_to_user + Array.wrap(api_key_permissions&.gems).each_with_index do |name, idx| + errors.add("api_key_permissions.gems[#{idx}]", "(#{name}) does not belong to user #{user.display_handle}") if user.rubygems.where(name:).empty? + end + end + + attribute :access_policy, Types::JsonDeserializable.new(OIDC::AccessPolicy) + validates :access_policy, presence: true, nested: true + validate :all_condition_claims_are_known + + def all_condition_claims_are_known + return unless provider + known_claims = provider.configuration.claims_supported + access_policy.statements&.each_with_index do |s, si| + s.conditions&.each_with_index do |c, ci| + unless known_claims&.include?(c.claim) + errors.add("access_policy.statements[#{si}].conditions[#{ci}].claim", + "unknown claim for the provider") + c.errors.add(:claim, + "unknown claim for the provider") + end + end + end + end + + # https://www.crockford.com/base32.html + CROCKFORD_BASE_32_ALPHABET = ("0".."9").to_a + ("a".."z").to_a - %w[0 i l u] + validates :token, presence: true, uniqueness: true, length: { minimum: 32, maximum: 32 }, + format: { with: /\Arg_oidc_akr_[#{CROCKFORD_BASE_32_ALPHABET}]+\z/o } + + before_validation :generate_random_token, if: :new_record? + def generate_random_token + 5.times do + suffix = SecureRandom.random_bytes(20).unpack("C*").map do |byte| + idx = byte % 32 + CROCKFORD_BASE_32_ALPHABET[idx] + end.join + + self.token = "rg_oidc_akr_#{suffix}" + + return if self.class.where(token:).empty? + end + + raise "could not generate unique token" + end +end diff --git a/app/models/oidc/base_model.rb b/app/models/oidc/base_model.rb new file mode 100644 index 00000000000..79eda05a84c --- /dev/null +++ b/app/models/oidc/base_model.rb @@ -0,0 +1,69 @@ +class OIDC::BaseModel + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Serializers::JSON + + include SemanticLogger::Loggable + + # Taken from ActiveRecord::Base + concerning "Inspectable" do + included do + # Returns the contents of the record as a nicely formatted string. + def inspect + # We check defined?(@attributes) not to issue warnings if the object is + # allocated but not initialized. + inspection = if defined?(@attributes) && @attributes + attribute_names.filter_map do |name| + "#{name}: #{_read_attribute(name)}" if @attributes.key?(name) + end.join(", ") + else + "not initialized" + end + + "#<#{self.class} #{inspection}>" + end + + # Takes a PP and prettily prints this record to it, allowing you to get a nice result from pp record + # when pp is required. + def pretty_print(pp) + pp.object_address_group(self) do + if defined?(@attributes) && @attributes + attr_names = attribute_names.select { |name| @attributes.key?(name) } + pp.seplist(attr_names, proc { pp.text "," }) do |attr_name| + pp.breakable " " + pp.group(1) do + pp.text attr_name + pp.text ":" + pp.breakable + value = _read_attribute(attr_name) + pp.pp value + end + end + else + pp.breakable " " + pp.text "not initialized" + end + end + end + end + end + + concerning "Attributes" do + included do + def [](attr_name) + _read_attribute(attr_name) { |n| missing_attribute(n, caller) } + end + end + end + + concerning "Equality" do + included do + def ==(other) + self.class == other.class && + ((attributes.keys | other.attributes.keys).all? do |k| + self[k] == other[k] + end) + end + end + end +end diff --git a/app/models/oidc/id_token.rb b/app/models/oidc/id_token.rb new file mode 100644 index 00000000000..2e03f5d45ec --- /dev/null +++ b/app/models/oidc/id_token.rb @@ -0,0 +1,51 @@ +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, 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 + + validates :jwt, presence: true + validate :jti_uniqueness + + def self.provider_id(oidc_provider_id) + joins(:api_key_role).where(api_key_role: { oidc_provider_id: }) + end + + def payload + { + api_key_role_token: api_key_role.token, + jwt: jwt.slice("claims", "header") + } + end + + def as_json(*args) + payload.as_json(*args) + end + + def to_xml(options = {}) + payload.to_xml(options.merge(root: "oidc:id_token")) + end + + def to_yaml(*args) + payload.to_yaml(*args) + end + + def jti + jwt&.dig("claims", "jti") + end + + def claims + jwt&.dig("claims") + end + + def header + jwt&.dig("header") + end + + def jti_uniqueness + relation = self.class.where("(jwt->>'claims')::jsonb->>'jti' = ?", jti) + relation = relation.provider_id(api_key_role.oidc_provider_id) if api_key_role + return unless relation.where.not(id: self).exists? + errors.add("jwt.claims.jti", "must be unique") + end +end diff --git a/app/models/oidc/provider.rb b/app/models/oidc/provider.rb new file mode 100644 index 00000000000..f9380f794c4 --- /dev/null +++ b/app/models/oidc/provider.rb @@ -0,0 +1,38 @@ +class OIDC::Provider < ApplicationRecord + validate :issuer_match, if: :configuration + before_validation -> { configuration&.expected_issuer = issuer } + + validates :configuration, nested: true + validates :issuer, uniqueness: { ignore_case: true } + + has_many :api_key_roles, class_name: "OIDC::ApiKeyRole", inverse_of: :provider, foreign_key: :oidc_provider_id, dependent: :restrict_with_exception + has_many :users, through: :api_key_roles, inverse_of: :oidc_providers + has_many :id_tokens, through: :api_key_roles, inverse_of: :provider + + has_many :audits, as: :auditable, dependent: :nullify + + class Configuration < ::OpenIDConnect::Discovery::Provider::Config::Response + attr_optional required_attributes.delete(:authorization_endpoint) + + def initialize(hash) + super(hash.deep_symbolize_keys) + end + + def valid? + super + errors.delete(:authorization_endpoint, :blank) + errors.none? + end + end + + attribute :configuration, Types::JsonDeserializable.new(Configuration) + + attribute :jwks, Types::JsonDeserializable.new(JSON::JWK::Set) + + private + + def issuer_match + return if issuer == configuration.issuer + errors.add :configuration, "issuer (#{configuration.issuer}) does not match the provider issuer: #{issuer}" + end +end diff --git a/app/models/pusher.rb b/app/models/pusher.rb index 804b4774032..9c33e118443 100644 --- a/app/models/pusher.rb +++ b/app/models/pusher.rb @@ -4,10 +4,20 @@ class Pusher include TraceTagger include SemanticLogger::Loggable - attr_reader :user, :spec, :message, :code, :rubygem, :body, :version, :version_id, :size + attr_reader :api_key, :user, :spec, :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 - def initialize(user, body, remote_ip = "", scoped_rubygem = nil) - @user = user @body = StringIO.new(body.read) @size = @body.size @remote_ip = remote_ip @@ -112,6 +122,7 @@ def find size: size, sha256: sha256, pusher: user, + pusher_api_key: api_key, cert_chain: spec.cert_chain set_tags "gemcutter.rubygem.version" => @version.number, "gemcutter.rubygem.platform" => @version.platform diff --git a/app/models/user.rb b/app/models/user.rb index 5721c439e5d..0e2ded10f1e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -40,6 +40,10 @@ class User < ApplicationRecord has_many :audits, as: :auditable, dependent: :nullify + 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 + validates :email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true validates :unconfirmed_email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true diff --git a/app/models/version.rb b/app/models/version.rb index 2fe862a7b0a..eca574c897f 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -7,6 +7,7 @@ class Version < ApplicationRecord has_many :dependencies, -> { order("rubygems.name ASC").includes(:rubygem) }, dependent: :destroy, inverse_of: "version" 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 before_validation :set_canonical_number, if: :number_changed? before_validation :full_nameify! diff --git a/app/policies/api_key_policy.rb b/app/policies/api_key_policy.rb index 2b839fd00f1..bb391458a03 100644 --- a/app/policies/api_key_policy.rb +++ b/app/policies/api_key_policy.rb @@ -11,4 +11,5 @@ def avo_show? has_association :api_key_rubygem_scope has_association :ownership + has_association :oidc_id_token end diff --git a/app/policies/oidc/api_key_role_policy.rb b/app/policies/oidc/api_key_role_policy.rb new file mode 100644 index 00000000000..9491a6f934b --- /dev/null +++ b/app/policies/oidc/api_key_role_policy.rb @@ -0,0 +1,16 @@ +class OIDC::ApiKeyRolePolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.all + end + end + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? + def avo_create? = rubygems_org_admin? + def avo_update? = rubygems_org_admin? + def act_on? = rubygems_org_admin? + + has_association :provider + has_association :id_tokens +end diff --git a/app/policies/oidc/id_token_policy.rb b/app/policies/oidc/id_token_policy.rb new file mode 100644 index 00000000000..fd94f93797a --- /dev/null +++ b/app/policies/oidc/id_token_policy.rb @@ -0,0 +1,14 @@ +class OIDC::IdTokenPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.all + end + end + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? + + has_association :provider + has_association :api_key_role + has_association :api_key +end diff --git a/app/policies/oidc/provider_policy.rb b/app/policies/oidc/provider_policy.rb new file mode 100644 index 00000000000..200fd3a69e6 --- /dev/null +++ b/app/policies/oidc/provider_policy.rb @@ -0,0 +1,15 @@ +class OIDC::ProviderPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.all + end + end + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? + def avo_create? = rubygems_org_admin? + def avo_update? = rubygems_org_admin? + def act_on? = rubygems_org_admin? + + has_association :api_key_roles +end diff --git a/app/views/avo/partials/_head.html.erb b/app/views/avo/partials/_head.html.erb new file mode 100644 index 00000000000..98fa5ee8857 --- /dev/null +++ b/app/views/avo/partials/_head.html.erb @@ -0,0 +1,7 @@ +<%= javascript_tag 'avo.custom', defer: true, type: :module, nonce: true do %> +import * as Stimulus from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.umd.js"; +import * as NestedForm from "https://unpkg.com/stimulus-rails-nested-form/dist/stimulus-rails-nested-form.umd.js"; + +const application = window.Stimulus.Application.start(); +application.register("nested-form", window.StimulusRailsNestedForm); +<% end %> diff --git a/app/views/avo/partials/_pre_head.html.erb b/app/views/avo/partials/_pre_head.html.erb new file mode 100644 index 00000000000..e69de29bb2d diff --git a/config/database.yml.sample b/config/database.yml.sample index 87b10c4720a..e9205af8a23 100644 --- a/config/database.yml.sample +++ b/config/database.yml.sample @@ -21,9 +21,9 @@ test: pool: 5 timeout: 5000 -webauthn-review: +oidc-api-token: <<: *default - database: rubygems_webauthn_review + database: rubygems_oidc_api_token min_messages: error pool: 30 reconnect: true diff --git a/config/deploy/ingress.yaml.erb b/config/deploy/ingress.yaml.erb index 1b8e300d200..70f5fb33474 100644 --- a/config/deploy/ingress.yaml.erb +++ b/config/deploy/ingress.yaml.erb @@ -11,6 +11,7 @@ metadata: alb.ingress.kubernetes.io/target-group-attributes: load_balancing.algorithm.type=least_outstanding_requests alb.ingress.kubernetes.io/tags: Env=<%= environment %>,Service=rubygems.org alb.ingress.kubernetes.io/healthcheck-path: /internal/ping + alb.ingress.kubernetes.io/load-balancer-name: <%= environment %>-rubygems-org spec: tls: - hosts: diff --git a/config/deploy/oidc-api-token/db-migrate.yaml.erb b/config/deploy/oidc-api-token/db-migrate.yaml.erb new file mode 120000 index 00000000000..39978cd7e3f --- /dev/null +++ b/config/deploy/oidc-api-token/db-migrate.yaml.erb @@ -0,0 +1 @@ +../db-migrate.yaml.erb \ No newline at end of file diff --git a/config/deploy/oidc-api-token/ingress.yaml.erb b/config/deploy/oidc-api-token/ingress.yaml.erb new file mode 120000 index 00000000000..87a75e51b0e --- /dev/null +++ b/config/deploy/oidc-api-token/ingress.yaml.erb @@ -0,0 +1 @@ +../ingress.yaml.erb \ No newline at end of file diff --git a/config/deploy/oidc-api-token/jobs.yaml.erb b/config/deploy/oidc-api-token/jobs.yaml.erb new file mode 120000 index 00000000000..98d525a385d --- /dev/null +++ b/config/deploy/oidc-api-token/jobs.yaml.erb @@ -0,0 +1 @@ +../jobs.yaml.erb \ No newline at end of file diff --git a/config/deploy/oidc-api-token/nginx-configmap.yaml.erb b/config/deploy/oidc-api-token/nginx-configmap.yaml.erb new file mode 120000 index 00000000000..ac475eec91d --- /dev/null +++ b/config/deploy/oidc-api-token/nginx-configmap.yaml.erb @@ -0,0 +1 @@ +../nginx-configmap.yaml.erb \ No newline at end of file diff --git a/config/deploy/oidc-api-token/ownership-requests-notify-daily.yaml.erb b/config/deploy/oidc-api-token/ownership-requests-notify-daily.yaml.erb new file mode 120000 index 00000000000..137a21a41cc --- /dev/null +++ b/config/deploy/oidc-api-token/ownership-requests-notify-daily.yaml.erb @@ -0,0 +1 @@ +../ownership-requests-notify-daily.yaml.erb \ No newline at end of file diff --git a/config/deploy/oidc-api-token/secrets.ejson b/config/deploy/oidc-api-token/secrets.ejson new file mode 100644 index 00000000000..4fd2cd04d29 --- /dev/null +++ b/config/deploy/oidc-api-token/secrets.ejson @@ -0,0 +1,33 @@ +{ + "_public_key": "02f76c6eb89d620eaaf561363136a30c5762a36db10a420fbe815055543db954", + "kubernetes_secrets": { + "oidc-api-token": { + "_type": "Opaque", + "data": { + "secret_key_base": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:iHOo47qmHmfaK1SRLjrT52aQBSTgMi8X:WN4+I63pe4rXZIueVP7i9ZGn7EhL/42haxLpxFyRk3PDEBnAAD0ov8BqRcr/HERGfkrKIRDq9GyWOQxSU150rVjlzsCQShGwba/i/Utc/teRDC6ahgT9oTzWOvJ5Ee0lRvBiw8jnptZHqaSNutIbV9WW20a+SfK01WXdyeDEN/uC5w3M3JQmyG1r6KXGMdyO]", + "database_url": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:33z/QZnU/Et7a9co6QLB6riG8erSFjiQ:D/bT+DQ3ZY104fpRCBha8sXZHXx3IEsXwKTzewQsIKwiPS0VPUwG4u0GXO6D2rj8BzeGJRGM8cA+h3X71MI7+B2VMolanI5c1ZFkJbV2+EELDFQC+PvtjKFmizyYO84vGw3TVi1/ydxWyepXtuCLzHkXUFBqi1GXmbFJ+c7gyboJmzjYKWVpL7oC8a//oYGn4bs6oNGSycr9X7YjGVEJeWJXKRYYBwWsP1Su]", + "aws_access_key_id": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:GUxYoOzgAwORuMsY4cv2EaNyiNZigIvz:zgVr0Zjx4o81YpFbMkcupAh4k/OKMbeYNAT5Fz6gBma1FtYx]", + "aws_secret_access_key": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:KV0VSxUGRJdQzLL9BtjKtMb6WDCqiea4:RMDXqPBegalvAtlRhMOIgFcd3VF+qcnsn+P116WPIMnv6KaRNpXdGhNgd6lxXcn7U5FD5csHhmk=]", + "honeybadger_api_key": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:5PSnBVzcDkCK6H82yxJTKJOwuhAVRNrz:G6XwcGfrs4r+xQK5K1rcsiuvvh3XxS1E]", + "_fastly_api_key": "", + "_fastly_service_id": "", + "_fastly_domains": "oidc-api-token.rubygems.org", + "elasticsearch_url": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:7kR5j65WgQO4pWUW+DO5J4yi75QM7xEX:UNgg/B/DYw3K9GqEGfJmYP7XDeqieEZqtkhHazm6hbXCpgGwq8lKI01ZfUYjKffcHJt0fQX01zNxRlI0fcj/bFhIAqezEFpzOReMyBci8QSSq7MVmdDEGAspxrxV7ROFo+7dbYia3GQDSt3xGzbjMg==]", + "memcached_endpoint": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:/8BT+sMo+K767gO15tEzhamvyC2a0787:2vGA/A0CjIi0fpGo4D7r6St+T95n4hg+7k9xv1BZn4MbS061Zb+PzWmxUADlsoVhz8P5RKOlVydqJ2E/8VzHeMIAKDQdbEYH9FHkcXmC]", + "sendgrid_username": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:zD2mq1fPcPeZRfLSBtaX3ceXcT8oona5:QJXQdZsjgzrRms1NxRSEuYR7sBlhCA==]", + "sendgrid_password": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:2rz4fkZzLsazb6LsdlDaUnN9DQtQ7wXZ:8NpHvR73pXiaS7940OOWXnyEGBy6OIbBEvY8aQESzLj02tiPv2kxzA2xgrlXuJPPjfHEE+2ZtOCU6go1+ES/t/u7RXA17ltXD+3wTN5B0VbxDuF6HQ==]", + "sendgrid_webhook_username": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:eG+NZXVUKJkiAgfEdIgiK6V859yLBN39:MjXQSLgTAAnGarklCQ3bZIEZ31Md0k0dplO+2TkSZnLdP+7trVjADW5w+gs1YA==]", + "sendgrid_webhook_password": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:3AoFnthst9w//PFri8ygec28oKJRCBa0:yKfqkXErdIeBhd8/raLA/80rzmptR5Ux2FxvedNRZjXhMcA9CwWR185SfAPc/zC3cu2u82Wg6gA3iCE=]", + "_slack_hook": "", + "_client_id": "", + "github_key": "EJ[1:eloNczyhpMywH1q1Tn/WZequ8LXB4czmIrpKbBjiOSU=:vfc00i7WCwITlSopqW2xQUfrgNAqnL3d:TbJ57r6JvNPZrly9OGs9ZHD8BlZyzBIc5tdw1iOBsmJQjPSi]", + "github_secret": "EJ[1:eloNczyhpMywH1q1Tn/WZequ8LXB4czmIrpKbBjiOSU=:KqTAQamp3VaY49bFwHPrT+T6/hgv1Gb2:eKaJ88wNGHyR3AeOoCVgtZMaFBV9Isp5xqqeghE1+Ksfiy+uLz6yu8qHu+AWGyswPHRpLNzpUUs=]", + "avo_license_key": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:9ej5LZZHfGbmh4pNCg3rE+mFLdZUg9fg:GsQjCH3QVmuRWcePNId/rFkSlXU40Avf69+GBZO9gIfWE2FI9LmDJLiCYHzZg678KDaSBw==]", + "_datadog_csp_api_key": "", + "_hook_relay_account_id": "", + "_hook_relay_hook_id": "", + "launch_darkly_sdk_key": "EJ[1:5AM/k5a98sQgd+E/Fn/GmEitKrpsVTmycCUGwrQg5mA=:4aM8O/+HGWd4Z1bV6gem71f/Meb6Ig4O:AYAK8IT2ci5NKFLoMbjiKyE8tn4FSLU1OrMW19cDSXmlYTCTFxb6zctHsG+E6EARRpDoklVNIJM=]" + } + } + } +} diff --git a/config/deploy/oidc-api-token/service.yaml.erb b/config/deploy/oidc-api-token/service.yaml.erb new file mode 120000 index 00000000000..43b6405a14c --- /dev/null +++ b/config/deploy/oidc-api-token/service.yaml.erb @@ -0,0 +1 @@ +../service.yaml.erb \ No newline at end of file diff --git a/config/deploy/oidc-api-token/shoryuken.yaml.erb b/config/deploy/oidc-api-token/shoryuken.yaml.erb new file mode 120000 index 00000000000..32db126af93 --- /dev/null +++ b/config/deploy/oidc-api-token/shoryuken.yaml.erb @@ -0,0 +1 @@ +../shoryuken.yaml.erb \ No newline at end of file diff --git a/config/deploy/oidc-api-token/web.yaml.erb b/config/deploy/oidc-api-token/web.yaml.erb new file mode 120000 index 00000000000..ab677473d3c --- /dev/null +++ b/config/deploy/oidc-api-token/web.yaml.erb @@ -0,0 +1 @@ +../web.yaml.erb \ No newline at end of file diff --git a/config/deploy/service.yaml.erb b/config/deploy/service.yaml.erb index e41bfafab5a..780282d1e79 100644 --- a/config/deploy/service.yaml.erb +++ b/config/deploy/service.yaml.erb @@ -6,6 +6,7 @@ metadata: name: web annotations: service.beta.kubernetes.io/aws-load-balancer-internal: 0.0.0.0/0 + service.beta.kubernetes.io/aws-load-balancer-name: <%= environment %>-rubygems-org spec: type: LoadBalancer ports: diff --git a/config/environments/oidc-api-token.rb b/config/environments/oidc-api-token.rb new file mode 100644 index 00000000000..a2b545e9f8d --- /dev/null +++ b/config/environments/oidc-api-token.rb @@ -0,0 +1,112 @@ +# rubocop:disable Naming/FileName +# rubocop:enable Naming/FileName + +require Rails.root.join("config", "secret") if Rails.root.join("config", "secret.rb").file? +require_relative "../../lib/gemcutter/middleware/redirector" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.cache_classes = true + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Attempt to read encrypted secrets from `config/secrets.yml.enc`. + # Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or + # `config/secrets.yml.key`. + # config.read_encrypted_secrets = true + + # Disable serving static files from the `/public` folder by default since + # Apache or NGINX already handles this. + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + config.public_file_server.headers = { + 'Cache-Control' => 'max-age=315360000, public', + 'Expires' => 'Thu, 31 Dec 2037 23:55:55 GMT' + } + + # Compress JavaScripts and CSS. + config.assets.js_compressor = :terser + config.assets.css_compressor = :sass + + # Do not fallback to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.action_controller.asset_host = 'http://assets.example.com' + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache + # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + + # Mount Action Cable outside main process or domain + # config.action_cable.mount_path = nil + # config.action_cable.url = 'wss://example.com/cable' + # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + config.ssl_options = { + hsts: { expires: 365.days, subdomains: false }, + redirect: { + exclude: ->(request) { request.path.start_with?('/internal') } + } + } + + # Use the lowest log level to ensure availability of diagnostic information + # when problems arise. + config.log_level = ENV['RAILS_LOG_LEVEL'].present? ? ENV['RAILS_LOG_LEVEL'].to_sym : :info + config.rails_semantic_logger.format = :json + config.rails_semantic_logger.semantic = true + SemanticLogger.add_appender(io: $stdout, formatter: :json) + + # Prepend all log lines with the following tags. + # config.log_tags = [ :request_id ] + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment) + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "gemcutter_#{Rails.env}" + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + config.action_mailer.default_url_options = { host: Gemcutter::HOST, + protocol: Gemcutter::PROTOCOL } + + # roadie-rails recommends not setting action_mailer.asset_host and use its own configuration for URL options + config.roadie.url_options = { host: Gemcutter::HOST, scheme: Gemcutter::PROTOCOL } + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = [:en] + + # Send deprecation notices to registered listeners. + config.active_support.deprecation = :notify + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + config.cache_store = :mem_cache_store, ENV['MEMCACHED_ENDPOINT'], { + failover: true, + socket_timeout: 1.5, + socket_failure_delay: 0.2, + compress: true, + compression_min_size: 524_288, + value_max_bytes: 2_097_152 # 2MB + } + + config.middleware.use Gemcutter::Middleware::Redirector +end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index a92f1a43579..7192151be9e 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -10,7 +10,8 @@ policy.img_src :self, "https://secure.gaug.es", "https://gravatar.com", "https://www.gravatar.com", "https://secure.gravatar.com", "https://*.fastly-insights.com", "https://avatars.githubusercontent.com" policy.object_src :none - policy.script_src :self, "https://secure.gaug.es", "https://www.fastly-insights.com" + policy.script_src :self, "https://secure.gaug.es", "https://www.fastly-insights.com", + "https://unpkg.com/@hotwired/stimulus/dist/stimulus.umd.js", "https://unpkg.com/stimulus-rails-nested-form/dist/stimulus-rails-nested-form.umd.js" policy.style_src :self, "https://fonts.googleapis.com" policy.connect_src :self, "https://s3-us-west-2.amazonaws.com/rubygems-dumps/", "https://*.fastly-insights.com", "https://fastly-insights.com", "https://api.github.com", "http://localhost:*" diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 7f0e433afa3..bd215540566 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -3,4 +3,5 @@ # Configure sensitive parameters which will be filtered from the log file. Rails.application.config.filter_parameters += %I[ password passw secret token _key crypt salt certificate otp ssn api_key recovery_codes seed + jwt ] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 34d3c4bbe46..521a346028e 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -20,4 +20,5 @@ inflect.acronym "OAuthable" inflect.acronym "GitHub" inflect.acronym "StatsD" + inflect.acronym "OIDC" end diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb index d2afa35c0fc..5c87d949ede 100644 --- a/config/initializers/zeitwerk.rb +++ b/config/initializers/zeitwerk.rb @@ -5,4 +5,7 @@ # expected file lib/shoryuken/sqs_worker.rb to define constant Shoryuken::SqsWorker Rails.autoloaders.main.ignore(Rails.root.join("lib/shoryuken")) -Rails.autoloaders.once.inflector.inflect("http" => "HTTP") +Rails.autoloaders.once.inflector.inflect( + "http" => "HTTP", + "oidc" => "OIDC" +) diff --git a/config/locales/de.yml b/config/locales/de.yml index d57f626133f..5147a4050c2 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -56,6 +56,13 @@ de: blocked: models: user: + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: api_keys: create: success: diff --git a/config/locales/en.yml b/config/locales/en.yml index b31368c4eca..70e7eb3f472 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -65,6 +65,13 @@ en: blocked: "domain '%{domain}' has been blocked for spamming. Please use a valid personal email." models: user: User + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: "%{value} seconds must be between 5 minutes (300 seconds) and 1 day (86,400 seconds)" api_keys: create: success: "Created new API key" diff --git a/config/locales/es.yml b/config/locales/es.yml index 13434776486..45f64a00d34 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -67,6 +67,13 @@ es: blocked: models: user: + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: api_keys: create: success: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index fda87d3c582..691c131181e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -67,6 +67,13 @@ fr: blocked: models: user: + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: api_keys: create: success: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index b36cf8112d6..0feac72ed97 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -60,6 +60,13 @@ ja: blocked: models: user: + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: api_keys: create: success: diff --git a/config/locales/nl.yml b/config/locales/nl.yml index c1086dc9694..7fbffe8b3dc 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -59,6 +59,13 @@ nl: blocked: models: user: + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: api_keys: create: success: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 0d966f84193..2378c16e2eb 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -66,6 +66,13 @@ pt-BR: blocked: models: user: Usuário + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: api_keys: create: success: diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 0bea775f114..cf43c548c30 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -5,13 +5,11 @@ zh-CN: failure_when_forbidden: 请再次确认 URL 或尝试重新提交 feed_latest: RubyGems.org | 最新的 Gem feed_subscribed: RubyGems.org | 订阅的 Gem - footer_about_html: - RubyGems.org 是 Ruby 社区的 Gem 托管服务。 + footer_about_html: RubyGems.org 是 Ruby 社区的 Gem 托管服务。 立即 发布您的 Gem安装它们。 使用 API 来查找更多 可用的 Gem快来成为一名贡献者吧! 由您自己改善我们的网站。 - footer_sponsors_html: - RubyGems.org 是通过与更大的Ruby社区的合作得以实现的。 + footer_sponsors_html: RubyGems.org 是通过与更大的Ruby社区的合作得以实现的。 Fastly 提供带宽和 CDN 支持, Ruby Central 涵盖基础设施成本,并且 资助正在进行的开发和运营工作。 @@ -66,6 +64,13 @@ zh-CN: blocked: "域名 '%{domain}' 因发送垃圾邮件已被禁用。请使用另外有效的个人邮箱。" models: user: 用户 + activemodel: + errors: + models: + oidc/api_key_permissions: + attributes: + valid_for: + inclusion: api_keys: create: success: "新的 API 密钥已创建" @@ -204,7 +209,7 @@ zh-CN: subject: 您的账户已经从 RubyGems.org 中删除 body_html: 您从 RubyGems.org 中删除账户的请求已经被处理。您随时可以使用我们的 %{sign_up} 页面注册新的账号。 deletion_failed: - title: 删除失败 + title: 删除失败 subtitle: 抱歉! subject: 您在 RubyGems.org 上关于删除账户的请求失败了 body_html: 您已请求在 RubyGems.org 中删除账户。很遗憾,我们无法处理您的请求。请稍后重试。如果问题依然存在,请 %{contact} 我们。 @@ -212,7 +217,7 @@ zh-CN: subject: 您改变了您在 RubyGems.org 上的邮件通知设置 title: 邮件通知 subtitle: 你好啊,%{handle}! - 'on': "开启" + "on": "开启" off_html: 关闭' + sendgrid_webhook_username: '<%= ENV["SENDGRID_WEBHOOK_USERNAME"] %>' + sendgrid_webhook_password: '<%= ENV["SENDGRID_WEBHOOK_PASSWORD"] %>' + # Do not keep production secrets in the repository, # instead read values from the environment. staging: diff --git a/db/migrate/20230404233417_create_oidc_providers.rb b/db/migrate/20230404233417_create_oidc_providers.rb new file mode 100644 index 00000000000..b75afbc2b74 --- /dev/null +++ b/db/migrate/20230404233417_create_oidc_providers.rb @@ -0,0 +1,11 @@ +class CreateOIDCProviders < ActiveRecord::Migration[7.0] + def change + create_table :oidc_providers do |t| + t.text :issuer, index: { unique: true } + t.jsonb :configuration + t.jsonb :jwks + + t.timestamps + end + end +end diff --git a/db/migrate/20230405000852_create_oidc_api_key_roles.rb b/db/migrate/20230405000852_create_oidc_api_key_roles.rb new file mode 100644 index 00000000000..714c27c3eac --- /dev/null +++ b/db/migrate/20230405000852_create_oidc_api_key_roles.rb @@ -0,0 +1,13 @@ +class CreateOIDCApiKeyRoles < ActiveRecord::Migration[7.0] + def change + create_table :oidc_api_key_roles do |t| + t.references :oidc_provider, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.jsonb :api_key_permissions, null: false + t.string :name, null: false + t.jsonb :access_policy, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20230410033351_add_expires_at_to_api_keys.rb b/db/migrate/20230410033351_add_expires_at_to_api_keys.rb new file mode 100644 index 00000000000..b360d63968f --- /dev/null +++ b/db/migrate/20230410033351_add_expires_at_to_api_keys.rb @@ -0,0 +1,5 @@ +class AddExpiresAtToApiKeys < ActiveRecord::Migration[7.0] + def change + add_column :api_keys, :expires_at, :timestamp + end +end diff --git a/db/migrate/20230410052651_create_oidc_id_tokens.rb b/db/migrate/20230410052651_create_oidc_id_tokens.rb new file mode 100644 index 00000000000..fd33c7c6d2f --- /dev/null +++ b/db/migrate/20230410052651_create_oidc_id_tokens.rb @@ -0,0 +1,12 @@ +class CreateOIDCIdTokens < ActiveRecord::Migration[7.0] + def change + create_table :oidc_id_tokens do |t| + t.references :oidc_api_key_role, null: false, foreign_key: true + t.jsonb :jwt, null: false + t.references :oidc_provider, null: false, foreign_key: true + t.references :api_key, null: true, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20230410063932_add_api_key_to_versions.rb b/db/migrate/20230410063932_add_api_key_to_versions.rb new file mode 100644 index 00000000000..b16fcefdab1 --- /dev/null +++ b/db/migrate/20230410063932_add_api_key_to_versions.rb @@ -0,0 +1,5 @@ +class AddApiKeyToVersions < ActiveRecord::Migration[7.0] + def change + add_reference :versions, :pusher_api_key, null: true, foreign_key: { to_table: :api_keys } + end +end diff --git a/db/migrate/20230719013340_add_token_to_oidc_api_key_role.rb b/db/migrate/20230719013340_add_token_to_oidc_api_key_role.rb new file mode 100644 index 00000000000..15d0429f6ad --- /dev/null +++ b/db/migrate/20230719013340_add_token_to_oidc_api_key_role.rb @@ -0,0 +1,6 @@ +class AddTokenToOIDCApiKeyRole < ActiveRecord::Migration[7.0] + def change + add_column :oidc_api_key_roles, :token, :string, null: false, unique: true, limit: 32 + add_index :oidc_api_key_roles, :token, unique: true + end +end diff --git a/db/migrate/20230804215243_remove_oidc_provider_from_oidc_id_token.rb b/db/migrate/20230804215243_remove_oidc_provider_from_oidc_id_token.rb new file mode 100644 index 00000000000..72f143095b0 --- /dev/null +++ b/db/migrate/20230804215243_remove_oidc_provider_from_oidc_id_token.rb @@ -0,0 +1,5 @@ +class RemoveOIDCProviderFromOIDCIdToken < ActiveRecord::Migration[7.0] + def change + remove_column :oidc_id_tokens, :oidc_provider_id, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index d86f1b27268..b1fd27c98c9 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_08_03_182938) do +ActiveRecord::Schema[7.0].define(version: 2023_08_04_215243) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" enable_extension "pgcrypto" @@ -53,6 +53,7 @@ t.boolean "mfa", default: false, null: false t.datetime "soft_deleted_at" t.string "soft_deleted_rubygem_name" + t.datetime "expires_at", precision: nil t.index ["hashed_key"], name: "index_api_keys_on_hashed_key", unique: true t.index ["user_id"], name: "index_api_keys_on_user_id" end @@ -235,6 +236,39 @@ t.index ["task_name", "status", "created_at"], name: "index_maintenance_tasks_runs", order: { created_at: :desc } end + create_table "oidc_api_key_roles", force: :cascade do |t| + t.bigint "oidc_provider_id", null: false + t.bigint "user_id", null: false + t.jsonb "api_key_permissions", null: false + t.string "name", null: false + t.jsonb "access_policy", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "token", limit: 32, null: false + t.index ["oidc_provider_id"], name: "index_oidc_api_key_roles_on_oidc_provider_id" + t.index ["token"], name: "index_oidc_api_key_roles_on_token", unique: true + t.index ["user_id"], name: "index_oidc_api_key_roles_on_user_id" + end + + create_table "oidc_id_tokens", force: :cascade do |t| + t.bigint "oidc_api_key_role_id", null: false + t.jsonb "jwt", null: false + t.bigint "api_key_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["api_key_id"], name: "index_oidc_id_tokens_on_api_key_id" + t.index ["oidc_api_key_role_id"], name: "index_oidc_id_tokens_on_oidc_api_key_role_id" + end + + create_table "oidc_providers", force: :cascade do |t| + t.text "issuer" + t.jsonb "configuration" + t.jsonb "jwks" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["issuer"], name: "index_oidc_providers_on_issuer", unique: true + end + create_table "ownership_calls", force: :cascade do |t| t.bigint "rubygem_id" t.bigint "user_id" @@ -372,6 +406,7 @@ t.bigint "pusher_id" t.text "cert_chain" t.string "canonical_number" + t.bigint "pusher_api_key_id" t.index "lower((full_name)::text)", name: "index_versions_on_lower_full_name" t.index ["built_at"], name: "index_versions_on_built_at" t.index ["canonical_number", "rubygem_id", "platform"], name: "index_versions_on_canonical_number_and_rubygem_id_and_platform", unique: true @@ -382,6 +417,7 @@ t.index ["number"], name: "index_versions_on_number" t.index ["position", "rubygem_id"], name: "index_versions_on_position_and_rubygem_id" t.index ["prerelease"], name: "index_versions_on_prerelease" + t.index ["pusher_api_key_id"], name: "index_versions_on_pusher_api_key_id" t.index ["pusher_id"], name: "index_versions_on_pusher_id" t.index ["rubygem_id", "number", "platform"], name: "index_versions_on_rubygem_id_and_number_and_platform", unique: true t.index ["rubygem_id"], name: "index_versions_on_rubygem_id" @@ -427,7 +463,12 @@ 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 "ownerships", "users", on_delete: :cascade + add_foreign_key "versions", "api_keys", column: "pusher_api_key_id" add_foreign_key "webauthn_credentials", "users" add_foreign_key "webauthn_verifications", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 74a9662e748..5a33271e6ab 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -158,6 +158,79 @@ } ).find_or_create_by!(github_id: "FAKE-not_an_admin") +github_oidc_provider = OIDC::Provider + .create_with( + configuration: { + issuer: "https://token.actions.githubusercontent.com", + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + response_types_supported: ["id_token"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + claims_supported: ["repo"] + } + ).find_or_create_by!(issuer: "https://token.actions.githubusercontent.com") + +author_oidc_api_key_role = author.oidc_api_key_roles.create_with( + api_key_permissions: { + gems: ["rubygem0"], + scopes: ["push_rubygem"], + valid_for: "PT20M" + }, + access_policy: { + statements: [ + effect: "allow", + principal: { + oidc: "https://token.actions.githubusercontent.com" + }, + conditions: [{ + operator: "string_equals", + claim: "repo", + value: "rubygems/rubygem0" + }], + ] + } +).find_or_create_by!( + name: "push-rubygem-1", + provider: github_oidc_provider +) + +author_oidc_api_key_role.user.api_keys.create_with( + hashed_key: "expiredhashedkey", + ownership: rubygem0.ownerships.find_by!(user: author), + push_rubygem: true, +).find_or_create_by!( + name: "push-rubygem-1-expired", +).tap do |api_key| + OIDC::IdToken.find_or_create_by!( + api_key:, + jwt: { claims: {jti: "expired"}, header: {}}, + api_key_role: author_oidc_api_key_role + ) + api_key.touch(:expires_at, time: "2020-01-01T00:00:00Z") +end + +author_oidc_api_key_role.user.api_keys.create_with( + hashed_key: "unexpiredhashedkey", + ownership: rubygem0.ownerships.find_by!(user: author), + push_rubygem: true, + expires_at: "2120-01-01T00:00:00Z" +).find_or_create_by!( + name: "push-rubygem-1-unexpired", +).tap do |api_key| + OIDC::IdToken.find_or_create_by!( + api_key:, + jwt: { claims: {jti: "unexpired"}, header: {}}, + api_key_role: author_oidc_api_key_role + ) +end + +author.api_keys.find_or_create_by!( + user: author, + hashed_key: "unexpiredmanualhashedkey", + name: "Manual", + push_rubygem: true, +) + 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/doc/erd.dot b/doc/erd.dot index e5a11c34c2b..8ab0d60f489 100644 --- a/doc/erd.dot +++ b/doc/erd.dot @@ -33,6 +33,7 @@ m_ApiKey [label = < + @@ -267,6 +268,35 @@ m_LogTicket [label = <
access_webhooks boolean ∗
add_owner boolean ∗
expires_at datetime
hashed_key string ∗
index_rubygems boolean ∗
last_accessed_at datetime
time_running float ∗
>]; +"m_OIDC::ApiKeyRole" [label = < + +
OIDC::ApiKeyRole
+| + + + + + +
access_policy jsonb ∗
api_key_permissions jsonb ∗
name string ∗
token string (32) ∗ U
+>]; +"m_OIDC::IdToken" [label = < + +
OIDC::IdToken
+| + + +
jwt jsonb ∗
+>]; +"m_OIDC::Provider" [label = < + +
OIDC::Provider
+| + + + + +
configuration jsonb
issuer text U
jwks jsonb
+>]; m_Ownership [label = <
Ownership
@@ -460,10 +490,9 @@ m_WebauthnVerification [label = < m_OwnershipCall [arrowhead = "normal", arrowtail = "none", weight = "3"]; m_User -> m_Deletion [arrowhead = "normal", arrowtail = "none", weight = "3"]; - m_User -> m_ApiKey [arrowhead = "normal", arrowtail = "none", weight = "3"]; m_ApiKey -> m_ApiKeyRubygemScope [arrowhead = "none", arrowtail = "none", weight = "2"]; - m_ApiKey -> m_Ownership [style = "dotted", arrowhead = "none", arrowtail = "none", weight = "1", constraint = "false"]; m_Ownership -> m_ApiKeyRubygemScope [arrowhead = "normal", arrowtail = "none", weight = "2"]; + "m_OIDC::Provider" -> m_Audit [arrowhead = "normal", arrowtail = "none", weight = "1"]; m_Rubygem -> m_Audit [arrowhead = "normal", arrowtail = "none", weight = "1"]; m_User -> m_Audit [arrowhead = "normal", arrowtail = "none", weight = "1"]; m_WebHook -> m_Audit [arrowhead = "normal", arrowtail = "none", weight = "1"]; @@ -479,6 +508,17 @@ m_WebauthnVerification [label = <
m_Linkset [arrowhead = "none", arrowtail = "none", weight = "2"]; m_User -> m_Version [arrowhead = "normal", arrowtail = "none", weight = "3"]; + m_ApiKey -> m_Version [arrowhead = "normal", arrowtail = "none", weight = "2"]; + "m_OIDC::Provider" -> "m_OIDC::ApiKeyRole" [arrowhead = "normal", arrowtail = "none", weight = "2"]; + "m_OIDC::Provider" -> m_User [style = "dotted", arrowhead = "normal", arrowtail = "none", weight = "1", constraint = "false"]; + "m_OIDC::Provider" -> "m_OIDC::IdToken" [style = "dotted", arrowhead = "normal", arrowtail = "none", weight = "2", constraint = "false"]; + "m_OIDC::ApiKeyRole" -> "m_OIDC::IdToken" [arrowhead = "normal", arrowtail = "none", weight = "2"]; + m_ApiKey -> "m_OIDC::IdToken" [arrowhead = "none", arrowtail = "none", weight = "2"]; + "m_OIDC::IdToken" -> m_User [style = "dotted", arrowhead = "normal", arrowtail = "none", weight = "3", constraint = "false"]; + m_User -> m_ApiKey [arrowhead = "normal", arrowtail = "none", weight = "3"]; + m_ApiKey -> m_Ownership [style = "dotted", arrowhead = "none", arrowtail = "none", weight = "1", constraint = "false"]; + m_User -> "m_OIDC::ApiKeyRole" [arrowhead = "normal", arrowtail = "none", weight = "3"]; + "m_OIDC::ApiKeyRole" -> m_ApiKey [style = "dotted", arrowhead = "normal", arrowtail = "none", weight = "1", constraint = "false"]; m_User -> m_WebHook [arrowhead = "normal", arrowtail = "none", weight = "3"]; "m_GoodJob::Execution" -> "m_GoodJob::DiscreteExecution" [arrowhead = "normal", arrowtail = "none", weight = "2"]; "m_GoodJob::Job" -> "m_GoodJob::DiscreteExecution" [arrowhead = "normal", arrowtail = "none", weight = "2"]; diff --git a/doc/erd.svg b/doc/erd.svg index cf33e64a7df..fed4c1e0f38 100644 --- a/doc/erd.svg +++ b/doc/erd.svg @@ -4,878 +4,969 @@ - - + + Gemcutter - -RubyGems.org domain model + +RubyGems.org domain model m_Admin::GitHubUser - -Admin::GitHubUser - -avatar_url -string -github_id -string ∗ U -info_data -json ∗ -is_admin -boolean -login -string ∗ -oauth_token -string + +Admin::GitHubUser + +avatar_url +string +github_id +string ∗ U +info_data +json ∗ +is_admin +boolean +login +string ∗ +oauth_token +string m_Audit - -Audit - -action -string ∗ -auditable_type -string ∗ -audited_changes -text -comment -string + +Audit + +action +string ∗ +auditable_type +string ∗ +audited_changes +text +comment +string - + m_Admin::GitHubUser->m_Audit - - + + m_ApiKey - -ApiKey - -access_webhooks -boolean ∗ -add_owner -boolean ∗ -hashed_key -string ∗ -index_rubygems -boolean ∗ -last_accessed_at -datetime -mfa -boolean ∗ -name -string ∗ -push_rubygem -boolean ∗ -remove_owner -boolean ∗ -show_dashboard -boolean ∗ -soft_deleted_at -datetime (6,0) -soft_deleted_rubygem_name -string -yank_rubygem -boolean ∗ + +ApiKey + +access_webhooks +boolean ∗ +add_owner +boolean ∗ +expires_at +datetime +hashed_key +string ∗ +index_rubygems +boolean ∗ +last_accessed_at +datetime +mfa +boolean ∗ +name +string ∗ +push_rubygem +boolean ∗ +remove_owner +boolean ∗ +show_dashboard +boolean ∗ +soft_deleted_at +datetime (6,0) +soft_deleted_rubygem_name +string +yank_rubygem +boolean ∗ m_ApiKeyRubygemScope - -ApiKeyRubygemScope + +ApiKeyRubygemScope - + m_ApiKey->m_ApiKeyRubygemScope - + + + + +m_OIDC::IdToken + +OIDC::IdToken + +jwt +jsonb ∗ + + + +m_ApiKey->m_OIDC::IdToken + - + m_Ownership - -Ownership - -confirmed_at -datetime -owner_notifier -boolean ∗ -ownership_request_notifier -boolean ∗ -push_notifier -boolean ∗ -token -string -token_expires_at -datetime - - - -m_ApiKey->m_Ownership - + +Ownership + +confirmed_at +datetime +owner_notifier +boolean ∗ +ownership_request_notifier +boolean ∗ +push_notifier +boolean ∗ +token +string +token_expires_at +datetime + + + +m_Version + +Version + +authors +text +built_at +datetime +canonical_number +string +cert_chain +text +description +text +full_name +string ∗ U +indexed +boolean +info_checksum +string +latest +boolean +licenses +string +metadata +hstore ∗ +number +string +platform +string +position +integer +prerelease +boolean +required_ruby_version +string +required_rubygems_version +string (255) +requirements +text +sha256 +string +size +integer +summary +text +yanked_at +datetime +yanked_info_checksum +string + + + +m_ApiKey->m_Version + + m_Deletion - -Deletion - -number -string ∗ -platform -string -rubygem -string ∗ + +Deletion + +number +string ∗ +platform +string +rubygem +string ∗ m_Dependency - -Dependency - -requirements -string ∗ -scope -string -unresolved_name -string + +Dependency + +requirements +string ∗ +scope +string +unresolved_name +string m_GemDownload - -GemDownload - -count -integer (8) + +GemDownload + +count +integer (8) m_GemNameReservation - -GemNameReservation - -name -string ∗ U + +GemNameReservation + +name +string ∗ U m_GemTypoException - -GemTypoException - -info -text -name -string ∗ U + +GemTypoException + +info +text +name +string ∗ U m_GoodJob::BaseExecution - -GoodJob::BaseExecution - -active_job_id -uuid -batch_callback_id -uuid -batch_id -uuid -concurrency_key -text -cron_at -datetime (6,0) -cron_key -text -error -text -executions_count -integer -finished_at -datetime (6,0) -is_discrete -boolean -job_class -text -performed_at -datetime (6,0) -priority -integer -queue_name -text -retried_good_job_id -uuid -scheduled_at -datetime (6,0) -serialized_params -jsonb + +GoodJob::BaseExecution + +active_job_id +uuid +batch_callback_id +uuid +batch_id +uuid +concurrency_key +text +cron_at +datetime (6,0) +cron_key +text +error +text +executions_count +integer +finished_at +datetime (6,0) +is_discrete +boolean +job_class +text +performed_at +datetime (6,0) +priority +integer +queue_name +text +retried_good_job_id +uuid +scheduled_at +datetime (6,0) +serialized_params +jsonb m_GoodJob::BatchRecord - -GoodJob::BatchRecord - -callback_priority -integer -callback_queue_name -text -description -text -discarded_at -datetime (6,0) -enqueued_at -datetime (6,0) -finished_at -datetime (6,0) -on_discard -text -on_finish -text -on_success -text -serialized_properties -jsonb + +GoodJob::BatchRecord + +callback_priority +integer +callback_queue_name +text +description +text +discarded_at +datetime (6,0) +enqueued_at +datetime (6,0) +finished_at +datetime (6,0) +on_discard +text +on_finish +text +on_success +text +serialized_properties +jsonb m_GoodJob::Execution - -GoodJob::Execution - -batch_callback_id -uuid -concurrency_key -text -cron_at -datetime (6,0) -cron_key -text -error -text -executions_count -integer -finished_at -datetime (6,0) -is_discrete -boolean -job_class -text -performed_at -datetime (6,0) -priority -integer -queue_name -text -retried_good_job_id -uuid -scheduled_at -datetime (6,0) -serialized_params -jsonb + +GoodJob::Execution + +batch_callback_id +uuid +concurrency_key +text +cron_at +datetime (6,0) +cron_key +text +error +text +executions_count +integer +finished_at +datetime (6,0) +is_discrete +boolean +job_class +text +performed_at +datetime (6,0) +priority +integer +queue_name +text +retried_good_job_id +uuid +scheduled_at +datetime (6,0) +serialized_params +jsonb - + m_GoodJob::BatchRecord->m_GoodJob::Execution - - + + m_GoodJob::Job - -GoodJob::Job - -concurrency_key -text -cron_at -datetime (6,0) -cron_key -text -error -text -executions_count -integer -finished_at -datetime (6,0) -id -uuid ∗ -is_discrete -boolean -job_class -text -performed_at -datetime (6,0) -priority -integer -queue_name -text -retried_good_job_id -uuid -scheduled_at -datetime (6,0) -serialized_params -jsonb + +GoodJob::Job + +concurrency_key +text +cron_at +datetime (6,0) +cron_key +text +error +text +executions_count +integer +finished_at +datetime (6,0) +id +uuid ∗ +is_discrete +boolean +job_class +text +performed_at +datetime (6,0) +priority +integer +queue_name +text +retried_good_job_id +uuid +scheduled_at +datetime (6,0) +serialized_params +jsonb - + m_GoodJob::BatchRecord->m_GoodJob::Job - - + + m_GoodJob::DiscreteExecution - -GoodJob::DiscreteExecution - -error -text -finished_at -datetime (6,0) -job_class -text -queue_name -text -scheduled_at -datetime (6,0) -serialized_params -jsonb + +GoodJob::DiscreteExecution + +error +text +finished_at +datetime (6,0) +job_class +text +queue_name +text +scheduled_at +datetime (6,0) +serialized_params +jsonb - + m_GoodJob::Execution->m_GoodJob::DiscreteExecution - - + + - + m_GoodJob::Job->m_GoodJob::DiscreteExecution - - + + - + m_GoodJob::Job->m_GoodJob::Execution - - + + m_GoodJob::Process - -GoodJob::Process - -state -jsonb + +GoodJob::Process + +state +jsonb m_GoodJob::Setting - -GoodJob::Setting - -key -text -value -jsonb + +GoodJob::Setting + +key +text +value +jsonb m_Linkset - -Linkset - -bugs -string -code -string -docs -string -home -string -mail -string -wiki -string + +Linkset + +bugs +string +code +string +docs +string +home +string +mail +string +wiki +string m_LogTicket - -LogTicket - -backend -integer -directory -string -key -string -processed_count -integer -status -string + +LogTicket + +backend +integer +directory +string +key +string +processed_count +integer +status +string m_MaintenanceTasks::Run - -MaintenanceTasks::Run - -arguments -text -backtrace -text -cursor -string -ended_at -datetime -error_class -string -error_message -string -job_id -string -lock_version -integer ∗ -started_at -datetime -status -string ∗ -task_name -string ∗ -tick_count -integer (8) ∗ -tick_total -integer (8) -time_running -float ∗ + +MaintenanceTasks::Run + +arguments +text +backtrace +text +cursor +string +ended_at +datetime +error_class +string +error_message +string +job_id +string +lock_version +integer ∗ +started_at +datetime +status +string ∗ +task_name +string ∗ +tick_count +integer (8) ∗ +tick_total +integer (8) +time_running +float ∗ + + + +m_OIDC::ApiKeyRole + +OIDC::ApiKeyRole + +access_policy +jsonb ∗ +api_key_permissions +jsonb ∗ +name +string ∗ +token +string (32) ∗ U + + + +m_OIDC::ApiKeyRole->m_ApiKey + + + + + +m_OIDC::ApiKeyRole->m_OIDC::IdToken + + + + + +m_User + +User + +api_key +string +blocked_email +string +confirmation_token +string (128) +email +string ∗ U +email_confirmed +boolean ∗ +email_reset +boolean +encrypted_password +string (128) +full_name +string +handle +string U +hide_email +boolean +mail_fails +integer +mfa_hashed_recovery_codes +string +mfa_level +integer +remember_token +string (128) +remember_token_expires_at +datetime +salt +string (128) +token +string (128) +token_expires_at +datetime +totp_seed +string +twitter_username +string +unconfirmed_email +string +webauthn_id +string + + + +m_OIDC::IdToken->m_User + + + + + +m_OIDC::Provider + +OIDC::Provider + +configuration +jsonb +issuer +text U +jwks +jsonb + + + +m_OIDC::Provider->m_Audit + + + + + +m_OIDC::Provider->m_OIDC::ApiKeyRole + + + + + +m_OIDC::Provider->m_OIDC::IdToken + + + + + +m_OIDC::Provider->m_User + + - + m_Ownership->m_ApiKeyRubygemScope - - + + - + m_OwnershipCall - -OwnershipCall - -note -text ∗ -status -boolean ∗ + +OwnershipCall + +note +text ∗ +status +boolean ∗ - + m_OwnershipRequest - -OwnershipRequest - -note -text ∗ -status -integer (2) ∗ + +OwnershipRequest + +note +text ∗ +status +integer (2) ∗ m_OwnershipCall->m_OwnershipRequest - - + + - + m_Rubygem - -Rubygem - -indexed -boolean ∗ -name -string ∗ U -slug -string + +Rubygem + +indexed +boolean ∗ +name +string ∗ U +slug +string - + m_Rubygem->m_Audit - - + + - + m_Rubygem->m_Dependency - - + + - + m_Rubygem->m_GemDownload - - + - + m_Rubygem->m_Linkset - + - + m_Rubygem->m_Ownership - - + + m_Rubygem->m_OwnershipCall - - + + m_Rubygem->m_OwnershipRequest - - - + + - + m_Subscription - -Subscription + +Subscription m_Rubygem->m_Subscription - - - - - -m_User - -User - -api_key -string -blocked_email -string -confirmation_token -string (128) -email -string ∗ U -email_confirmed -boolean ∗ -email_reset -boolean -encrypted_password -string (128) -full_name -string -handle -string U -hide_email -boolean -mail_fails -integer -mfa_hashed_recovery_codes -string -mfa_level -integer -remember_token -string (128) -remember_token_expires_at -datetime -salt -string (128) -token -string (128) -token_expires_at -datetime -totp_seed -string -twitter_username -string -unconfirmed_email -string -webauthn_id -string - - - -m_Version - -Version - -authors -text -built_at -datetime -canonical_number -string -cert_chain -text -description -text -full_name -string ∗ U -indexed -boolean -info_checksum -string -latest -boolean -licenses -string -metadata -hstore ∗ -number -string -platform -string -position -integer -prerelease -boolean -required_ruby_version -string -required_rubygems_version -string (255) -requirements -text -sha256 -string -size -integer -summary -text -yanked_at -datetime -yanked_info_checksum -string + + - + m_Rubygem->m_Version - - + + - + m_WebHook - -WebHook - -disabled_at -datetime -disabled_reason -text -failure_count -integer -failures_since_last_success -integer -last_failure -datetime -last_success -datetime -successes_since_last_failure -integer -url -string ∗ + +WebHook + +disabled_at +datetime +disabled_reason +text +failure_count +integer +failures_since_last_success +integer +last_failure +datetime +last_success +datetime +successes_since_last_failure +integer +url +string ∗ - + m_Rubygem->m_WebHook - - + + - + m_SendgridEvent - -SendgridEvent - -email -string -event_type -string -occurred_at -datetime -payload -jsonb ∗ -sendgrid_id -string ∗ -status -string ∗ + +SendgridEvent + +email +string +event_type +string +occurred_at +datetime +payload +jsonb ∗ +sendgrid_id +string ∗ +status +string ∗ - + m_User->m_ApiKey - - + + - + m_User->m_Audit - - + + m_User->m_Deletion - - + + + + + +m_User->m_OIDC::ApiKeyRole + + - + m_User->m_Ownership - - + + m_User->m_OwnershipCall - - + + m_User->m_OwnershipRequest - - + + m_User->m_Subscription - - + + - + m_User->m_Version - - + + - + m_User->m_WebHook - - + + - + m_WebauthnCredential - -WebauthnCredential - -external_id -string ∗ U -nickname -string ∗ U -public_key -string ∗ -sign_count -integer (8) ∗ + +WebauthnCredential + +external_id +string ∗ U +nickname +string ∗ U +public_key +string ∗ +sign_count +integer (8) ∗ m_User->m_WebauthnCredential - - + + - + m_WebauthnVerification - -WebauthnVerification - -otp -string -otp_expires_at -datetime (6,0) -path_token -string (128) ∗ U -path_token_expires_at -datetime (6,0) ∗ + +WebauthnVerification + +otp +string +otp_expires_at +datetime (6,0) +path_token +string (128) ∗ U +path_token_expires_at +datetime (6,0) ∗ m_User->m_WebauthnVerification - + - + m_User::WithPrivateFields - -User::WithPrivateFields - -api_key -string -blocked_email -string -confirmation_token -string (128) -email -string ∗ U -email_confirmed -boolean ∗ -email_reset -boolean -encrypted_password -string (128) -full_name -string -handle -string U -hide_email -boolean -mail_fails -integer -mfa_hashed_recovery_codes -string -mfa_level -integer -remember_token -string (128) -remember_token_expires_at -datetime -salt -string (128) -token -string (128) -token_expires_at -datetime -totp_seed -string -twitter_username -string -unconfirmed_email -string -webauthn_id -string + +User::WithPrivateFields + +api_key +string +blocked_email +string +confirmation_token +string (128) +email +string ∗ U +email_confirmed +boolean ∗ +email_reset +boolean +encrypted_password +string (128) +full_name +string +handle +string U +hide_email +boolean +mail_fails +integer +mfa_hashed_recovery_codes +string +mfa_level +integer +remember_token +string (128) +remember_token_expires_at +datetime +salt +string (128) +token +string (128) +token_expires_at +datetime +totp_seed +string +twitter_username +string +unconfirmed_email +string +webauthn_id +string - + m_Version->m_Dependency - - + + - + m_Version->m_GemDownload - + - + m_WebHook->m_Audit - - + + diff --git a/lib/nested_validator.rb b/lib/nested_validator.rb new file mode 100644 index 00000000000..7bf4d213047 --- /dev/null +++ b/lib/nested_validator.rb @@ -0,0 +1,19 @@ +class NestedValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + case value + when Array + value.each_with_index do |v, i| + next if v.valid? + v.errors.each do |e| + record.errors.import(e, attribute: "#{attribute}[#{i}].#{e.attribute}") + end + end + else + if Array(value).reject(&:valid?).any? + value.errors.each do |e| + record.errors.import(e, attribute: "#{attribute}.#{e.attribute}") + end + end + end + end +end diff --git a/lib/types/array_of.rb b/lib/types/array_of.rb new file mode 100644 index 00000000000..dc79144441b --- /dev/null +++ b/lib/types/array_of.rb @@ -0,0 +1,18 @@ +class Types::ArrayOf < ActiveModel::Type::Value + def initialize(klass) + @klass = klass + super() + end + + def type = :array_of + + def changed_in_place?(raw_old_value, new_value) + deserialize(raw_old_value) != new_value + end + + def cast_value(value) + value&.map { member.cast(_1) } + end + + def member = @klass.is_a?(Symbol) ? ActiveModel::Type.lookup(@klass) : @klass +end diff --git a/lib/types/duration.rb b/lib/types/duration.rb new file mode 100644 index 00000000000..bab388087e7 --- /dev/null +++ b/lib/types/duration.rb @@ -0,0 +1,30 @@ +class Types::Duration < ActiveModel::Type::Value + def cast_value(value) + return value if value.blank? + + case value + when ActiveSupport::Duration + value + when String + if /\A\d+\z/.match?(value) + ActiveSupport::Duration.build(value.to_i) + else + ActiveSupport::Duration.parse(value) + end + when Integer + ActiveSupport::Duration.build(value) + else + raise ArgumentError, "Cannot cast #{value.inspect} to a Duration" + end + rescue ActiveSupport::Duration::ISO8601Parser::ParsingError + nil + end + + def serialize(duration) + duration.presence&.iso8601 + end + + def type_cast_for_schema(value) + serialize(value).inspect + end +end diff --git a/lib/types/json_deserializable.rb b/lib/types/json_deserializable.rb new file mode 100644 index 00000000000..9f2fba38ee2 --- /dev/null +++ b/lib/types/json_deserializable.rb @@ -0,0 +1,9 @@ +class Types::JsonDeserializable < ActiveRecord::Type::Json + def initialize(klass) + @klass = klass + super() + end + + def cast_value(value) = value.nil? || value.is_a?(@klass) ? super : @klass.new(super) + def deserialize(value) = cast_value(super) +end diff --git a/test/factories/oidc/api_key_role.rb b/test/factories/oidc/api_key_role.rb new file mode 100644 index 00000000000..8af57b7d94b --- /dev/null +++ b/test/factories/oidc/api_key_role.rb @@ -0,0 +1,23 @@ +FactoryBot.define do + factory :oidc_api_key_role, class: "OIDC::ApiKeyRole" do + provider factory: :oidc_provider + user + api_key_permissions do + { + scopes: ["push_rubygem"] + } + end + name { "GitHub Pusher" } + access_policy do + { + statements: [ + { effect: "allow", + principal: { oidc: provider.issuer }, + conditions: [ + { operator: "string_equals", claim: "sub", value: "repo:segiddins/oidc-test:ref:refs/heads/main" } + ] } + ] + } + end + end +end diff --git a/test/factories/oidc/id_token.rb b/test/factories/oidc/id_token.rb new file mode 100644 index 00000000000..9746b76ffd1 --- /dev/null +++ b/test/factories/oidc/id_token.rb @@ -0,0 +1,20 @@ +FactoryBot.define do + factory :oidc_id_token, class: "OIDC::IdToken" do + api_key_role factory: :oidc_api_key_role + api_key { association :api_key, user: api_key_role.user, key: SecureRandom.hex(20) } + jwt do + { + claims: { + claim1: "value1", + claim2: "value2", + jti: + }, + header: {} + } + end + + transient do + sequence(:jti) { |_n| SecureRandom.uuid } + end + end +end diff --git a/test/factories/oidc/provider.rb b/test/factories/oidc/provider.rb new file mode 100644 index 00000000000..f745ff2cc63 --- /dev/null +++ b/test/factories/oidc/provider.rb @@ -0,0 +1,67 @@ +FactoryBot.define do + factory :oidc_provider, class: "OIDC::Provider" do + sequence(:issuer) { |n| "https://#{n}.token.actions.githubusercontent.com" } + configuration do + { + issuer: issuer, + jwks_uri: "#{issuer}/.well-known/jwks", + subject_types_supported: %w[ + public + pairwise + ], + response_types_supported: [ + "id_token" + ], + claims_supported: %w[ + sub + aud + exp + iat + iss + jti + nbf + ref + repository + repository_id + repository_owner + repository_owner_id + run_id + run_number + run_attempt + actor + actor_id + workflow + workflow_ref + workflow_sha + head_ref + base_ref + event_name + ref_type + environment + environment_node_id + job_workflow_ref + job_workflow_sha + repository_visibility + runner_environment + ], + id_token_signing_alg_values_supported: [ + "RS256" + ], + scopes_supported: [ + "openid" + ] + } + end + jwks do + { + keys: [ + pkey&.to_jwk + ].compact + } + end + + transient do + pkey { OpenSSL::PKey::RSA.generate(2048) } + end + end +end diff --git a/test/functional/api_keys_controller_test.rb b/test/functional/api_keys_controller_test.rb index a04cd7e4c57..36df232f05a 100644 --- a/test/functional/api_keys_controller_test.rb +++ b/test/functional/api_keys_controller_test.rb @@ -258,21 +258,22 @@ class ApiKeysControllerTest < ActionController::TestCase should redirect_to("the index api key page") { profile_api_keys_path } - should "delete api key of user" do - assert_empty @user.api_keys + should "expire api key of user" do + assert_empty @user.api_keys.unexpired + refute_empty @user.api_keys end end context "with unsuccessful destroy" do setup do - ApiKey.any_instance.stubs(:destroy).returns(false) + ApiKey.any_instance.stubs(:expire!).returns(false) delete :destroy, params: { id: @api_key.id } end should redirect_to("the index api key page") { profile_api_keys_path } - should "not delete api key of user" do - refute_empty @user.api_keys + should "not expire api key of user" do + refute_empty @user.api_keys.unexpired end end end @@ -285,8 +286,8 @@ class ApiKeysControllerTest < ActionController::TestCase should respond_with :not_found - should "not delete the api key" do - assert ApiKey.find(@api_key.id) + should "not expire the api key" do + refute_predicate ApiKey.find(@api_key.id), :expired? end end end @@ -301,8 +302,8 @@ class ApiKeysControllerTest < ActionController::TestCase should redirect_to("the index api key page") { profile_api_keys_path } - should "delete all api key of user" do - assert_empty @user.api_keys + should "expire all api key of user" do + @user.api_keys.each { assert_predicate _1, :expired? } end end diff --git a/test/functional/passwords_controller_test.rb b/test/functional/passwords_controller_test.rb index a47744f11eb..b21799d579a 100644 --- a/test/functional/passwords_controller_test.rb +++ b/test/functional/passwords_controller_test.rb @@ -419,8 +419,9 @@ class PasswordsControllerTest < ActionController::TestCase should "change password" do refute_equal(@user.reload.encrypted_password, @old_encrypted_password) end - should "delete new api key" do - assert_empty @user.reload.api_keys + should "expire new api key" do + assert_empty @user.reload.api_keys.unexpired + refute_empty @user.reload.api_keys.expired end end end diff --git a/test/integration/api/v1/oidc/api_key_roles_test.rb b/test/integration/api/v1/oidc/api_key_roles_test.rb new file mode 100644 index 00000000000..73a1dd82236 --- /dev/null +++ b/test/integration/api/v1/oidc/api_key_roles_test.rb @@ -0,0 +1,367 @@ +require "test_helper" + +class Api::V1::OIDC::ApiKeyRolesTest < ActionDispatch::IntegrationTest + make_my_diffs_pretty! + + context "on GET to index" do + setup do + @role = create(:oidc_api_key_role) + @user = @role.user + @user_api_key = "12323" + @api_key = create(:api_key, user: @user, key: @user_api_key) + end + + should "return the user's roles" do + get api_v1_oidc_api_key_roles_path, + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal [ + { + "id" => @role.id, + "token" => @role.token, + "oidc_provider_id" => @role.oidc_provider_id, + "user_id" => @user.id, + "api_key_permissions" => { "scopes" => ["push_rubygem"], "valid_for" => 1800, "gems" => nil }, + "name" => "GitHub Pusher", + "access_policy" => { "statements" => [ + { "effect" => "allow", + "principal" => { "oidc" => @role.provider.issuer }, + "conditions" => [{ + "operator" => "string_equals", + "claim" => "sub", + "value" => "repo:segiddins/oidc-test:ref:refs/heads/main" + }] } + ] }, + "created_at" => @role.created_at.as_json, + "updated_at" => @role.updated_at.as_json + } + ], response.parsed_body + end + end + + context "on GET to show" do + setup do + @role = create(:oidc_api_key_role) + @user = @role.user + @user_api_key = "12323" + @api_key = create(:api_key, user: @user, key: @user_api_key) + end + + should "return the user's roles" do + get api_v1_oidc_api_key_role_path(@role.token), + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal( + { + "id" => @role.id, + "token" => @role.token, + "oidc_provider_id" => @role.oidc_provider_id, + "user_id" => @user.id, + "api_key_permissions" => { "scopes" => ["push_rubygem"], "valid_for" => 1800, "gems" => nil }, + "name" => "GitHub Pusher", + "access_policy" => { "statements" => [ + { "effect" => "allow", + "principal" => { "oidc" => @role.provider.issuer }, + "conditions" => [{ + "operator" => "string_equals", + "claim" => "sub", + "value" => "repo:segiddins/oidc-test:ref:refs/heads/main" + }] } + ] }, + "created_at" => @role.created_at.as_json, + "updated_at" => @role.updated_at.as_json + }, response.parsed_body + ) + end + end + + def jwt(claims = @claims, key: @pkey) + JSON::JWT.new(claims).sign(key.to_jwk) + end + + context "on POST to assume_role" do + setup do + @pkey = OpenSSL::PKey::RSA.generate(2048) + @role = create(:oidc_api_key_role, provider: build(:oidc_provider, issuer: "https://token.actions.githubusercontent.com", pkey: @pkey)) + @user = @role.user + + @claims = { + "aud" => "https://github.com/segiddins", + "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" => ".github/workflows/token.yml", + "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 + + context "with an unknown id" do + should "response not found" do + post assume_role_api_v1_oidc_api_key_role_path(@role.id + 1), + params: {}, + headers: {} + + assert_response :not_found + end + end + + context "with a known id" do + context "with an invalid jwt" do + should "respond not found" do + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: "1#{jwt}" + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a jwt that does not match the jwks" do + should "respond not found" do + @role.provider.jwks.each { _1["n"] += "NO" } + @role.provider.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a nbf after the current time" do + should "respond not found" do + @claims["exp"] = Time.now.to_i + 360 + @claims["nbf"] = Time.now.to_i + 60 + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a exp before the current time" do + should "respond not found" do + @claims["exp"] = Time.now.to_i - 60 + @claims["nbf"] = Time.now.to_i - 360 + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with exp before nbf" do + should "respond not found" do + @claims["exp"] = Time.now.to_i - 60 + @claims["nbf"] = Time.now.to_i + 360 + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with a jwt with the wrong issuer" do + should "respond not found" do + @role.provider.configuration.issuer = "https://example.com" + @role.provider.update!(issuer: "https://example.com") + + post assume_role_api_v1_oidc_api_key_role_path(@role), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + context "with matching conditions" do + should "return API key" do + @role.access_policy.statements.first.conditions << OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "sub", + value: "repo:segiddins/oidc-test:ref:refs/heads/main" + ) + @role.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :created + + resp = response.parsed_body + + assert_match(/^rubygems_/, resp["rubygems_api_key"]) + assert_equal({ + "rubygems_api_key" => resp["rubygems_api_key"], + "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d", + "scopes" => ["push_rubygem"], + "expires_at" => 30.minutes.from_now + }, resp) + hashed_key = @user.api_keys.sole.hashed_key + + assert_equal hashed_key, Digest::SHA256.hexdigest(resp["rubygems_api_key"]) + end + end + + context "with permissions scoped to a gem" do + should "return API key" do + gem_name = create(:rubygem, owners: [@role.user], number: "1.0.0").name + @role.api_key_permissions.gems = [gem_name] + @role.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :created + + resp = response.parsed_body + + assert_match(/^rubygems_/, resp["rubygems_api_key"]) + assert_equal({ + "rubygems_api_key" => resp["rubygems_api_key"], + "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d", + "scopes" => ["push_rubygem"], + "expires_at" => 30.minutes.from_now, + "gem" => Rubygem.find_by!(name: gem_name).as_json + }, resp) + hashed_key = @user.api_keys.sole.hashed_key + + assert_equal hashed_key, Digest::SHA256.hexdigest(resp["rubygems_api_key"]) + assert_equal gem_name, @user.api_keys.sole.ownership.rubygem.name + end + end + + context "with mismatched conditions" do + should "return not found" do + @role.access_policy.statements.first.conditions << OIDC::AccessPolicy::Statement::Condition.new( + operator: "string_equals", + claim: "sub", + value: "repo:other/oidc-test:ref:refs/heads/main" + ) + @role.save! + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :not_found + assert_empty @user.api_keys + end + end + + should "return an API token" do + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :created + + resp = response.parsed_body + + assert_match(/^rubygems_/, resp["rubygems_api_key"]) + assert_equal({ + "rubygems_api_key" => resp["rubygems_api_key"], + "name" => "GitHub Pusher-79685b65-945d-450a-a3d8-a36bcf72c23d", + "scopes" => ["push_rubygem"], + "expires_at" => 30.minutes.from_now + }, resp) + hashed_key = @user.api_keys.sole.hashed_key + + assert_equal hashed_key, Digest::SHA256.hexdigest(resp["rubygems_api_key"]) + + oidc_id_token = @role.id_tokens.sole + + assert_equal hashed_key, oidc_id_token.api_key.hashed_key + assert_equal @role.provider, oidc_id_token.provider + assert_equal( + { + "claims" => @claims, + "header" => { + "alg" => "RS256", + "kid" => @pkey.to_jwk[:kid], + "typ" => "JWT" + } + }, + oidc_id_token.jwt + ) + + post assume_role_api_v1_oidc_api_key_role_path(@role.token), + params: { + jwt: jwt.to_s + }, + headers: {} + + assert_response :unprocessable_entity + assert_equal({ + "errors" => { "jwt.claims.jti" => ["must be unique"] } + }, response.parsed_body) + end + end + end +end diff --git a/test/integration/api/v1/oidc/id_tokens_test.rb b/test/integration/api/v1/oidc/id_tokens_test.rb new file mode 100644 index 00000000000..ecfab025740 --- /dev/null +++ b/test/integration/api/v1/oidc/id_tokens_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class Api::V1::OIDC::IdTokensTest < ActionDispatch::IntegrationTest + make_my_diffs_pretty! + + setup do + @role = create(:oidc_api_key_role) + @user = @role.user + @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) + end + + context "on GET to index" do + should "return the user's roles" do + get api_v1_oidc_id_tokens_path, + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal [ + { + "api_key_role_token" => @id_token.api_key_role.token, + "jwt" => { + "claims" => @id_token.jwt["claims"], + "header" => @id_token.jwt["header"] + } + } + ], response.parsed_body + end + end + + context "on GET to show" do + should "return the user's id token" do + get api_v1_oidc_id_token_path(@id_token), + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + assert_equal( + { + "api_key_role_token" => @id_token.api_key_role.token, + "jwt" => { + "claims" => @id_token.jwt["claims"], + "header" => @id_token.jwt["header"] + } + }, response.parsed_body + ) + end + end +end diff --git a/test/integration/api/v1/oidc/providers_test.rb b/test/integration/api/v1/oidc/providers_test.rb new file mode 100644 index 00000000000..9d85bbbcacb --- /dev/null +++ b/test/integration/api/v1/oidc/providers_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class Api::V1::OIDC::ProvidersTest < ActionDispatch::IntegrationTest + make_my_diffs_pretty! + + setup do + @providers = create_list(:oidc_provider, 3) + + @user = create(:user) + @user_api_key = "12323" + @api_key = create(:api_key, user: @user, key: @user_api_key) + end + + context "on GET to index" do + should "return all providers" do + get api_v1_oidc_providers_path, + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + end + end + + context "on GET to show" do + should "return provider" do + get api_v1_oidc_provider_path(@providers[1]), + params: {}, + headers: { "HTTP_AUTHORIZATION" => @user_api_key } + + assert_response :success + end + end +end diff --git a/test/integration/avo/oidc_api_key_roles_controller_test.rb b/test/integration/avo/oidc_api_key_roles_controller_test.rb new file mode 100644 index 00000000000..91f3787307f --- /dev/null +++ b/test/integration/avo/oidc_api_key_roles_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::OIDCApiKeyRolesControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting api key roles as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_api_key_roles_path + + assert_response :success + + oidc_api_key_role = create(:oidc_api_key_role) + + get avo.resources_oidc_api_key_roles_path + + assert_response :success + page.assert_text oidc_api_key_role.name + + get avo.resources_oidc_api_key_role_path(oidc_api_key_role) + + assert_response :success + page.assert_text oidc_api_key_role.name + end +end diff --git a/test/integration/avo/oidc_id_tokens_controller_test.rb b/test/integration/avo/oidc_id_tokens_controller_test.rb new file mode 100644 index 00000000000..9d8f06b44a5 --- /dev/null +++ b/test/integration/avo/oidc_id_tokens_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::OIDCIdTokensControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting id tokens as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_id_tokens_path + + assert_response :success + + oidc_id_token = create(:oidc_id_token) + + get avo.resources_oidc_id_tokens_path + + assert_response :success + page.assert_text oidc_id_token.api_key.name + + get avo.resources_oidc_id_token_path(oidc_id_token) + + assert_response :success + page.assert_text oidc_id_token.jwt["claims"].values.first + end +end diff --git a/test/integration/avo/oidc_providers_controller_test.rb b/test/integration/avo/oidc_providers_controller_test.rb new file mode 100644 index 00000000000..52a0b7cd585 --- /dev/null +++ b/test/integration/avo/oidc_providers_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Avo::OIDCProvidersControllerTest < ActionDispatch::IntegrationTest + include AdminHelpers + + test "getting providers as admin" do + admin_sign_in_as create(:admin_github_user, :is_admin) + + get avo.resources_oidc_providers_path + + assert_response :success + + oidc_provider = create(:oidc_provider) + + get avo.resources_oidc_providers_path + + assert_response :success + page.assert_text oidc_provider.issuer + + get avo.resources_oidc_provider_path(oidc_provider) + + assert_response :success + page.assert_text oidc_provider.issuer + end +end diff --git a/test/jobs/store_version_contents_job_test.rb b/test/jobs/store_version_contents_job_test.rb index 6cf0099db99..17854a7d439 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.new(@user, @gem).process + Pusher.new(create(:api_key, user: @user), @gem).process @gem.rewind @gem_package = Gem::Package.new(@gem) @version = Version.last diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb index 08f0d13eb2f..1d7c0740f99 100644 --- a/test/models/api_key_test.rb +++ b/test/models/api_key_test.rb @@ -200,6 +200,15 @@ class ApiKeyTest < ActiveSupport::TestCase assert_contains api_key.errors[:base], "An invalid API key cannot be used. Please delete it and create a new one." end + should "be invalid if expired" do + api_key = create(:api_key, expires_at: 10.minutes.from_now) + + travel 20.minutes + + refute_predicate api_key, :valid? + assert_contains api_key.errors[:base], "An expired API key cannot be used. Please create a new one." + end + context "#mfa_authorized?" do setup do @api_key = create(:api_key) @@ -241,6 +250,30 @@ class ApiKeyTest < ActiveSupport::TestCase refute @api_key.mfa_authorized?(incorrect_otp) end end + + context "with oidc id token" do + setup do + create(:oidc_id_token, api_key: @api_key) + end + + should "return true if mfa not enabled for api key" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_gem_signin) + + assert @api_key.mfa_authorized?(nil) + end + + should "return true if mfa enabled for api" do + @api_key.user.enable_totp!(ROTP::Base32.random_base32, :ui_and_api) + + assert @api_key.mfa_authorized?(nil) + end + + should "return true if mfa enabled for api key" do + @api_key.update!(mfa: true) + + assert @api_key.mfa_authorized?(nil) + end + end end context "#mfa_enabled?" do diff --git a/test/models/oidc/access_policy_test.rb b/test/models/oidc/access_policy_test.rb new file mode 100644 index 00000000000..4f8b9b433d1 --- /dev/null +++ b/test/models/oidc/access_policy_test.rb @@ -0,0 +1,160 @@ +require "test_helper" + +class OIDC::AccessPolicyTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + should validate_presence_of :statements + + setup do + @role = build(:oidc_api_key_role) + end + + context "#verify_access!" do + context "with an unknown effect on matching statement" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "unknown", + principal: { oidc: "iss" }, + conditions: [] + }]) + end + + should "raise error" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise("Unhandled effect unknown") { @access_policy.verify_access!(jwt) } + end + + should "fail to validate" do + @access_policy.validate + + assert_equal ["is not included in the list"], @access_policy.errors.messages[:"statements[0].effect"] + end + end + + context "with an explicit deny" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "deny", + principal: { oidc: "iss" }, + conditions: [] + }]) + end + + should "raise AccessError" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + end + + context "with no statements" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: []) + end + + should "raise AccessError" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + end + + context "with string_equals condition" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "string_equals", + claim: "c", + value: "value" + }] + }]) + end + + should "raise AccessError when unequal" do + jwt = JSON::JWT.new({ iss: "iss", c: "not_value" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + + should "return nil when equal" do + jwt = JSON::JWT.new({ iss: "iss", c: "value" }) + + assert_nil @access_policy.verify_access!(jwt) + end + end + + context "with string_matches condition" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "string_matches", + claim: "c", + value: "\\A[v].{3}e.*" + }] + }]) + end + + should "raise AccessError when no match" do + jwt = JSON::JWT.new({ iss: "iss", c: "not_value" }) + assert_raise(OIDC::AccessPolicy::AccessError) { @access_policy.verify_access!(jwt) } + end + + should "return nil when matches" do + jwt = JSON::JWT.new({ iss: "iss", c: "value" }) + + assert_nil @access_policy.verify_access!(jwt) + end + end + + context "with condition with unknown operator" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "unknown", + claim: "c", + value: "" + }] + }]) + end + + should "raise" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise('Unknown operator "unknown"') { @access_policy.verify_access!(jwt) } + end + + should "fail to validate" do + @access_policy.validate + + assert_equal ["is not included in the list"], @access_policy.errors.messages[:"statements[0].conditions[0].operator"] + end + end + + context "with condition with wrong value type" do + setup do + @access_policy = OIDC::AccessPolicy.new(statements: [{ + effect: "allow", + principal: { oidc: "iss" }, + conditions: [{ + operator: "string_equals", + claim: "c", + value: 3 + }] + }]) + end + + should "raise" do + jwt = JSON::JWT.new({ iss: "iss" }) + assert_raise('Unknown operator "unknown"') { @access_policy.verify_access!(jwt) } + end + + should "fail to validate" do + @access_policy.validate + + assert_equal ["must be String"], @access_policy.errors.messages[:"statements[0].conditions[0].value"] + end + end + end +end diff --git a/test/models/oidc/api_key_permissions_test.rb b/test/models/oidc/api_key_permissions_test.rb new file mode 100644 index 00000000000..2d1b36cf0b7 --- /dev/null +++ b/test/models/oidc/api_key_permissions_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class OIDC::ApiKeyPermissionsTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + should validate_presence_of :scopes + should validate_presence_of :valid_for + + test "validates scopes are known" do + permissions = OIDC::ApiKeyPermissions.new(scopes: ["unknown"]) + permissions.validate + + assert_equal ["unknown scope: unknown"], permissions.errors.messages[:"scopes[0]"] + end + + test "validates scopes are unique" do + permissions = OIDC::ApiKeyPermissions.new(scopes: %w[openid openid]) + permissions.validate + + assert_equal ["must be unique"], permissions.errors.messages[:scopes] + end + + test "validates gems has maximum length of 1" do + permissions = OIDC::ApiKeyPermissions.new(gems: %w[a b]) + permissions.validate + + assert_equal ["is too long (maximum is 1 character)"], permissions.errors.messages[:gems] + end +end diff --git a/test/models/oidc/api_key_role_test.rb b/test/models/oidc/api_key_role_test.rb new file mode 100644 index 00000000000..a25dad9083c --- /dev/null +++ b/test/models/oidc/api_key_role_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class OIDC::ApiKeyRoleTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + should belong_to :provider + should belong_to :user + should have_many :id_tokens + should validate_presence_of :api_key_permissions + should validate_presence_of :access_policy + + setup do + @role = build(:oidc_api_key_role) + end + + test "inspect with nested attributes" do + assert_match "string_equals", @role.inspect + end + + test "pretty_inspect with nested attributes" do + assert_match "string_equals", @role.pretty_inspect + end + + test "validates gems belong to the user" do + @role.api_key_permissions.gems = ["does_not_exist"] + @role.validate + + assert_equal ["(does_not_exist) does not belong to user #{@role.user.handle}"], @role.errors.messages[:"api_key_permissions.gems[0]"] + end + + test "validates condition claims are known" do + @role.access_policy.statements = [OIDC::AccessPolicy::Statement.new( + effect: "allow", + conditions: [ + { operator: "string_equals", claim: "unknown", value: "" } + ], + principal: { oidc: "" } + )] + @role.validate + + assert_equal ["unknown claim for the provider"], @role.errors.messages[:"access_policy.statements[0].conditions[0].claim"] + end + + test "validates nested models" do + @role.access_policy.statements = [OIDC::AccessPolicy::Statement.new( + principal: { oidc: nil } + )] + @role.validate + + assert_equal ["can't be blank"], @role.errors.messages[:"access_policy.statements[0].principal.oidc"] + end +end diff --git a/test/models/oidc/base_model_test.rb b/test/models/oidc/base_model_test.rb new file mode 100644 index 00000000000..6fbb3f43288 --- /dev/null +++ b/test/models/oidc/base_model_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class OIDC::BaseModelTest < ActiveSupport::TestCase + make_my_diffs_pretty! + + context "with an uninitialized object" do + setup do + @model = OIDC::BaseModel.allocate + end + + should "inspect as not initializee" do + assert_match " not initialized", @model.inspect + end + + should "pretty_inspect as not initializee" do + assert_match " not initialized", @model.pretty_inspect + end + end + + context "with no attributes" do + setup do + @model = OIDC::BaseModel.new({}) + end + + should "inspect" do + assert_equal "#", @model.inspect + end + + should "pretty_inspect" do + assert_match(/#/, @model.pretty_inspect) + end + + should "compare equal to itself" do + assert_equal @model, @model.itself + end + + should "compare equal to a copy" do + assert_equal @model, OIDC::BaseModel.new({}) + end + end +end diff --git a/test/models/oidc/id_token_test.rb b/test/models/oidc/id_token_test.rb new file mode 100644 index 00000000000..db89b8a87f8 --- /dev/null +++ b/test/models/oidc/id_token_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class OIDC::IdTokenTest < ActiveSupport::TestCase + should belong_to :api_key_role + should belong_to :api_key + should have_one(:provider) + should have_one(:user) + + should validate_presence_of :jwt + + test "validates jti uniqueness" do + api_key_role = FactoryBot.create(:oidc_api_key_role) + id_token = FactoryBot.create(:oidc_id_token, api_key_role:) + assert_raises(ActiveRecord::RecordInvalid) do + FactoryBot.create(:oidc_id_token, provider: id_token.provider, jwt: id_token.jwt, api_key_role:) + end + end + + test "#to_json" do + id_token = FactoryBot.create(:oidc_id_token) + + assert_equal id_token.payload.to_json, id_token.to_json + end + + test "#to_xml" do + id_token = FactoryBot.create(:oidc_id_token) + + assert_equal id_token.payload.to_xml(root: "oidc:id_token"), id_token.to_xml + end + + test "#to_yaml" do + id_token = FactoryBot.create(:oidc_id_token) + + assert_equal id_token.payload.to_yaml, id_token.to_yaml + end +end diff --git a/test/models/oidc/provider_test.rb b/test/models/oidc/provider_test.rb new file mode 100644 index 00000000000..6b9b023145d --- /dev/null +++ b/test/models/oidc/provider_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class OIDC::ProviderTest < ActiveSupport::TestCase + should have_many :api_key_roles + should have_many :id_tokens + should have_many :users + + context "with an issuer that does not match the configuration" do + setup do + @provider = build(:oidc_provider, configuration: { issuer: "https//example.com/other" }) + end + + should "fail to validate" do + refute_predicate @provider, :valid? + assert_equal ["issuer (https//example.com/other) does not match the provider issuer: #{@provider.issuer}"], + @provider.errors.messages[:configuration] + end + end +end diff --git a/test/policies/oidc/api_key_role_policy_test.rb b/test/policies/oidc/api_key_role_policy_test.rb new file mode 100644 index 00000000000..ab590a2533d --- /dev/null +++ b/test/policies/oidc/api_key_role_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class OIDC::ApiKeyRolePolicyTest < ActiveSupport::TestCase + setup do + @api_key_role = FactoryBot.create(:oidc_api_key_role) + + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@api_key_role], Pundit.policy_scope!( + @admin, + OIDC::ApiKeyRole + ).to_a + end + + def test_avo_index + assert_predicate Pundit.policy!(@admin, OIDC::ApiKeyRole), :avo_index? + refute_predicate Pundit.policy!(@non_admin, OIDC::ApiKeyRole), :avo_index? + end + + def test_avo_show + assert_predicate Pundit.policy!(@admin, @api_key_role), :avo_show? + refute_predicate Pundit.policy!(@non_admin, @api_key_role), :avo_show? + end + + def test_avo_create + assert_predicate Pundit.policy!(@admin, OIDC::ApiKeyRole), :avo_create? + refute_predicate Pundit.policy!(@non_admin, OIDC::ApiKeyRole), :avo_create? + end + + def test_avo_update + assert_predicate Pundit.policy!(@admin, @api_key_role), :avo_update? + refute_predicate Pundit.policy!(@non_admin, @api_key_role), :avo_update? + end + + def test_avo_destroy + refute_predicate Pundit.policy!(@admin, @api_key_role), :avo_destroy? + refute_predicate Pundit.policy!(@non_admin, @api_key_role), :avo_destroy? + end +end diff --git a/test/policies/oidc/id_token_policy_test.rb b/test/policies/oidc/id_token_policy_test.rb new file mode 100644 index 00000000000..9b5c4ad7790 --- /dev/null +++ b/test/policies/oidc/id_token_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class OIDC::IdTokenPolicyTest < ActiveSupport::TestCase + setup do + @id_token = FactoryBot.create(:oidc_id_token) + + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@id_token], Pundit.policy_scope!( + @admin, + OIDC::IdToken + ).to_a + end + + def test_avo_index + assert_predicate Pundit.policy!(@admin, OIDC::IdToken), :avo_index? + refute_predicate Pundit.policy!(@non_admin, OIDC::IdToken), :avo_index? + end + + def test_avo_show + assert_predicate Pundit.policy!(@admin, @id_token), :avo_show? + refute_predicate Pundit.policy!(@non_admin, @id_token), :avo_show? + end + + def test_avo_create + refute_predicate Pundit.policy!(@admin, OIDC::IdToken), :avo_create? + refute_predicate Pundit.policy!(@non_admin, OIDC::IdToken), :avo_create? + end + + def test_avo_update + refute_predicate Pundit.policy!(@admin, @id_token), :avo_update? + refute_predicate Pundit.policy!(@non_admin, @id_token), :avo_update? + end + + def test_avo_destroy + refute_predicate Pundit.policy!(@admin, @id_token), :avo_destroy? + refute_predicate Pundit.policy!(@non_admin, @id_token), :avo_destroy? + end +end diff --git a/test/policies/oidc/provider_policy_test.rb b/test/policies/oidc/provider_policy_test.rb new file mode 100644 index 00000000000..6789c0b3c0c --- /dev/null +++ b/test/policies/oidc/provider_policy_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class OIDC::ProviderPolicyTest < ActiveSupport::TestCase + setup do + @provider = FactoryBot.create(:oidc_provider) + + @admin = FactoryBot.create(:admin_github_user, :is_admin) + @non_admin = FactoryBot.create(:admin_github_user) + end + + def test_scope + assert_equal [@provider], Pundit.policy_scope!( + @admin, + OIDC::Provider + ).to_a + end + + def test_avo_index + assert_predicate Pundit.policy!(@admin, OIDC::Provider), :avo_index? + refute_predicate Pundit.policy!(@non_admin, OIDC::Provider), :avo_index? + end + + def test_avo_show + assert_predicate Pundit.policy!(@admin, @provider), :avo_show? + refute_predicate Pundit.policy!(@non_admin, @provider), :avo_show? + end + + def test_avo_create + assert_predicate Pundit.policy!(@admin, OIDC::Provider), :avo_create? + refute_predicate Pundit.policy!(@non_admin, OIDC::Provider), :avo_create? + end + + def test_avo_update + assert_predicate Pundit.policy!(@admin, @provider), :avo_update? + refute_predicate Pundit.policy!(@non_admin, @provider), :avo_update? + end + + def test_avo_destroy + refute_predicate Pundit.policy!(@admin, @provider), :avo_destroy? + refute_predicate Pundit.policy!(@non_admin, @provider), :avo_destroy? + end +end diff --git a/test/system/avo/oidc_api_key_roles_test.rb b/test/system/avo/oidc_api_key_roles_test.rb new file mode 100644 index 00000000000..01b66514f4c --- /dev/null +++ b/test/system/avo/oidc_api_key_roles_test.rb @@ -0,0 +1,77 @@ +require "application_system_test_case" + +class Avo::OIDCApiKeyRolesSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + def sign_in_as(user) + OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new( + provider: "github", + uid: "1", + credentials: { + token: user.oauth_token, + expires: false + }, + info: { + name: user.login + } + ) + + stub_github_info_request(user.info_data) + + visit avo.root_path + click_button "Log in with GitHub" + + page.assert_text user.login + end + + test "manually changing roles" do + admin_user = create(:admin_github_user, :is_admin) + sign_in_as admin_user + + provider = create(:oidc_provider) + user = create(:user) + + visit avo.resources_oidc_api_key_roles_path + click_on "Create new oidc api key role" + + select provider.issuer, from: "oidc_api_key_role_oidc_provider_id" + select user.display_handle, from: "oidc_api_key_role_user_id" + fill_in "Name", with: "Role" + fill_in "Valid for", with: "PT15M" + fill_in "Comment", with: "A nice long comment" + + click_on "Save" + + page.assert_text "can't be blank" + page.assert_text "Access policy can't be blank" + + assert_field "oidc_api_key_role_oidc_provider_id", with: provider.id + assert_field "oidc_api_key_role_user_id", with: user.id + assert_field "Name", with: "Role" + assert_field "Valid for", with: "900" + + find("div[data-field-id='scopes'] tags").click + send_keys "push_rubygem", :enter + fill_in "Comment", with: "A nice long comment" + + click_on "Add another Statement" + click_on "Add another Condition" + + click_on "Save" + + fill_in "oidc_api_key_role_access_policy_statements_0__principal_oidc", with: provider.issuer + select "String Matches", from: "oidc_api_key_role_access_policy_statements_0__conditions_0__operator" + fill_in "oidc_api_key_role_access_policy_statements_0__conditions_0__claim", with: "sub" + fill_in "oidc_api_key_role_access_policy_statements_0__conditions_0__value", with: "sub-value" + fill_in "Comment", with: "A nice long comment" + + click_on "Save" + + page.assert_text "Role" + + role = provider.api_key_roles.sole + + assert_equal "string_matches", role.access_policy.statements[0].conditions[0].operator + assert_equal OIDC::ApiKeyPermissions.new(valid_for: 15.minutes, gems: [], scopes: ["push_rubygem"]), role.api_key_permissions + end +end diff --git a/test/system/avo/oidc_providers_test.rb b/test/system/avo/oidc_providers_test.rb new file mode 100644 index 00000000000..39d0bb25186 --- /dev/null +++ b/test/system/avo/oidc_providers_test.rb @@ -0,0 +1,173 @@ +require "application_system_test_case" + +class Avo::OIDCProvidersSystemTest < ApplicationSystemTestCase + make_my_diffs_pretty! + + def sign_in_as(user) + OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new( + provider: "github", + uid: "1", + credentials: { + token: user.oauth_token, + expires: false + }, + info: { + name: user.login + } + ) + + stub_github_info_request(user.info_data) + + visit avo.root_path + click_button "Log in with GitHub" + + page.assert_text user.login + end + + test "refreshing provider" do + admin_user = create(:admin_github_user, :is_admin) + sign_in_as admin_user + + provider = create(:oidc_provider, issuer: "https://token.actions.githubusercontent.com", configuration: nil, jwks: nil) + + visit avo.resources_oidc_provider_path(provider) + click_button "Actions" + click_on "Refresh OIDC Provider" + + stub_request(:get, "https://token.actions.githubusercontent.com/.well-known/openid-configuration").to_return( + status: 200, + body: { + issuer: "https://token.actions.githubusercontent.com", + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + subject_types_supported: %w[ + public + pairwise + ], + response_types_supported: [ + "id_token" + ], + claims_supported: %w[ + sub + aud + exp + iat + iss + jti + nbf + ref + repository + repository_id + repository_owner + repository_owner_id + run_id + run_number + run_attempt + actor + actor_id + workflow + workflow_ref + workflow_sha + head_ref + base_ref + event_name + ref_type + environment + environment_node_id + job_workflow_ref + job_workflow_sha + repository_visibility + runner_environment + ], + id_token_signing_alg_values_supported: [ + "RS256" + ], + scopes_supported: [ + "openid" + ] + }.to_json, + headers: { + "content-type" => "application/json; charset=utf-8" + } + ) + stub_request(:get, "https://token.actions.githubusercontent.com/.well-known/jwks").to_return( + status: 200, + body: { + keys: [ + { + n: "4WpHpoBYsVBVfSlfgnRbdPMxP3Eb7rFqE48e4pPM4qH_9EsUZIi21LjOu8UkKn14L4hrRfzfRHG7VQSbxXBU1Qa-xM5yVxdmfQZKBxQnPWaE1v7edjxq1ZYnqHIp90Uvn" \ + "w6798xMCSvI_V3FR8tix5GaoTgkixXlPc-ozifMyEZMmhvuhfDsSxQeTSHGPlWfGkX0id_gYzKPeI69EGtQ9ZN3PLTdoAI8jxlQ-jyDchi9h2ax6hgMLDsMZyiIXnF2UY" \ + "q4j36Cs5RgdC296d0hEOHN0WYZE-xPl7y_A9UHcVjrxeGfVOuTBXqjowofimn4ESnVXNReCsOwZCJlvJzfpQ", + kty: "RSA", + kid: "78167F727DEC5D801DD1C8784C704A1C880EC0E1", + alg: "RS256", + e: "AQAB", + use: "sig", + x5c: [ + "MIIDrDCCApSgAwIBAgIQMPdKi0TFTMqmg1HHo6FfsDANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8" \ + "uY29tMB4XDTIyMDEwNTE4NDcyMloXDTI0MDEwNTE4NTcyMlowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQ" \ + "YJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOFqR6aAWLFQVX0pX4J0W3TzMT9xG+6xahOPHuKTzOKh//RLFGSIttS4zrvFJCp9eC+Ia0X830Rxu1UEm8VwVNUGvsTOclcXZ" \ + "n0GSgcUJz1mhNb+3nY8atWWJ6hyKfdFL58Ou/fMTAkryP1dxUfLYseRmqE4JIsV5T3PqM4nzMhGTJob7oXw7EsUHk0hxj5VnxpF9Inf4GMyj3iOvRBrUPWTdzy03aACPI8Z" \ + "UPo8g3IYvYdmseoYDCw7DGcoiF5xdlGKuI9+grOUYHQtvendIRDhzdFmGRPsT5e8vwPVB3FY68Xhn1TrkwV6o6MKH4pp+BEp1VzUXgrDsGQiZbyc36UCAwEAAaOBtTCBsjA" \ + "OBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudm" \ + "lzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBRZBaZCR9ghvStfcWaGwuHGjrfTgzAdBgNVHQ4EFgQUWQWmQkfYIb0rX3FmhsLhxo6304MwDQYJKoZIhvcNAQELBQADggEBA" \ + "GNdfALe6mdxQ67QL8GlW4dfFwvCX87JOeZThZ9uCj1+x1xUnywoR4o5q2DVI/JCvBRPn0BUb3dEVWLECXDHGjblesWZGMdSGYhMzWRQjVNmCYBC1ZM5QvonWCBcGkd72mZx" \ + "0eFHnJCAP/TqEEpRvMHR+OOtSiZWV9zZpF1tf06AjKwT64F9V8PCmSIqPJXcTQXKKfkHZmGUk9AYF875+/FfzF89tCnT53UEh5BldFz0SAls+NhexbW/oOokBNCVqe+T2xX" \ + "izktbFnFAFaomvwjVSvIeu3i/0Ygywl+3s5izMEsZ1T1ydIytv4FZf2JCHgRpmGPWJ5A7TpxuHSiE8Do=" + ], + x5t: "eBZ_cn3sXYAd0ch4THBKHIgOwOE" + }, + { + n: "wgCsNL8S6evSH_AHBsps2ccIHSwLpuEUGS9GYenGmGkSKyWefKsZheKl_84voiUgduuKcKA2aWQezp9338LjtlBmTHjopzAeU-Q3_IvqNf7BfrEAzEyp-ymdhNzPTE7S" \ + "nmr5o_9AeiP1ZDBo35FaULgVUECJ3AzAM36zkURax3VNZRRZx1gb8lPUs9M5Yw6aZpHSOd6q_QzE8CP1OhGrAdoBzZ6ZCElon0kI-IuRLCwKptS7Yroi5-RtEKD2W458" \ + "axNAQ36Yw93N8kInUC1QZDPrKd4QfYiG68ywjBoxp_bjNg5kh4LJmq1mwyGdNQV6F1Ew_jYlmou2Y8wvHQRJPQ", + kty: "RSA", + kid: "52F197C481DE70112C441B4A9B37B53C7FCF0DB5", + alg: "RS256", + e: "AQAB", + use: "sig", + x5c: [ + "MIIDrDCCApSgAwIBAgIQLQnoXJ3HT6uPYvEofvOZ6zANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVka" \ + "W8uY29tMB4XDTIxMTIwNjE5MDUyMloXDTIzMTIwNjE5MTUyMlowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCAS" \ + "IwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMIArDS/Eunr0h/wBwbKbNnHCB0sC6bhFBkvRmHpxphpEislnnyrGYXipf/OL6IlIHbrinCgNmlkHs6fd9/C47ZQZkx" \ + "46KcwHlPkN/yL6jX+wX6xAMxMqfspnYTcz0xO0p5q+aP/QHoj9WQwaN+RWlC4FVBAidwMwDN+s5FEWsd1TWUUWcdYG/JT1LPTOWMOmmaR0jneqv0MxPAj9ToRqwHaAc2e" \ + "mQhJaJ9JCPiLkSwsCqbUu2K6IufkbRCg9luOfGsTQEN+mMPdzfJCJ1AtUGQz6yneEH2IhuvMsIwaMaf24zYOZIeCyZqtZsMhnTUFehdRMP42JZqLtmPMLx0EST0CAwEAA" \ + "aOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c2" \ + "8tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBTTNQQWmG4PZZsdfMeamCH1YcyDZTAdBgNVHQ4EFgQU0zUEFphuD2WbHXzHmpgh9WHMg2UwDQYJKoZIhvc" \ + "NAQELBQADggEBAK/d+HzBSRac7p6CTEolRXcBrBmmeJUDbBy20/XA6/lmKq73dgc/za5VA6Kpfd6EFmG119tl2rVGBMkQwRx8Ksr62JxmCw3DaEhE8ZjRARhzgSiljqXH" \ + "lk8TbNnKswHxWmi4MD2/8QhHJwFj3X35RrdMM4R0dN/ojLlWsY9jXMOAvcSBQPBqttn/BjNzvn93GDrVafyX9CPl8wH40MuWS/gZtXeYIQg5geQkHCyP96M5Sy8ZABOo9" \ + "MSIfPRw1F7dqzVuvliul9ZZGV2LsxmZCBtbsCkBau0amerigZjud8e9SNp0gaJ6wGhLbstCZIdaAzS5mSHVDceQzLrX2oe1h4k=" + ], + x5t: "UvGXxIHecBEsRBtKmze1PH_PDbU" + } + ] + }.to_json, + headers: { + "content-type" => "application/json; charset=utf-8" + } + ) + + fill_in "Comment", with: "A nice long comment" + click_on "Refresh" + + page.assert_text "Action ran successfully!" + page.assert_text provider.to_global_id.uri.to_s + + provider.reload + + audit = provider.audits.sole + + page.assert_text audit.id + assert_equal "OIDC::Provider", audit.auditable_type + assert_equal "Refresh OIDC Provider", audit.action + assert_equal( + "https://token.actions.githubusercontent.com/.well-known/jwks", + audit.audited_changes.dig("records", "gid://gemcutter/OIDC::Provider/#{provider.id}", "changes", "configuration", 1, "jwks_uri") + ) + assert_equal( + "78167F727DEC5D801DD1C8784C704A1C880EC0E1", + audit.audited_changes.dig("records", "gid://gemcutter/OIDC::Provider/#{provider.id}", "changes", "jwks", 1, "keys", 0, "kid") + ) + assert_equal admin_user, audit.admin_github_user + assert_equal "A nice long comment", audit.comment + end +end diff --git a/test/unit/seeds_test.rb b/test/unit/seeds_test.rb index d0f38536ac9..2fbd2a955c6 100644 --- a/test/unit/seeds_test.rb +++ b/test/unit/seeds_test.rb @@ -4,8 +4,8 @@ class SeedsTest < ActiveSupport::TestCase make_my_diffs_pretty! def all_records - ApplicationRecord.descendants.reject(&:abstract_class?).map do |record_class| - [record_class.name, record_class.all.map(&:attributes)] + ApplicationRecord.descendants.reject(&:abstract_class?).sort_by(&:name).to_h do |record_class| + [record_class.name, record_class.all.order(:id).map(&:attributes).as_json] end end @@ -16,7 +16,7 @@ def load_seed test "can load seeds idempotently" do load_seed - assert_no_changes -> { all_records } do + assert_no_changes "all_records" do load_seed end end diff --git a/test/unit/types/duration_test.rb b/test/unit/types/duration_test.rb new file mode 100644 index 00000000000..aeb7dfea82d --- /dev/null +++ b/test/unit/types/duration_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class Types::DurationTest < ActiveSupport::TestCase + setup do + @type = Types::Duration.new + end + + test "deserialize iso8601" do + assert_equal 15.minutes, @type.deserialize("PT15M") + end + + test "deserialize seconds as string" do + assert_equal 300.seconds, @type.deserialize("300") + end + + test "deserialize duration" do + assert_equal 300.seconds, @type.deserialize(300.seconds) + end + + test "deserialize unsupported value" do + assert_raise { @type.deserialize(Object.new) } + end + + test "deserialize unsupported string" do + assert_nil @type.deserialize("random string") + end + + test "serialize duration" do + assert_equal "P1D", @type.serialize(1.day) + end + + test "type_cast_for_schema" do + assert_equal '"P1D"', @type.type_cast_for_schema(1.day) + end +end