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 %>
+
+
+ <%= a_link 'javascript:void(0);', icon: 'trash', color: :red, style: :text, data: {action: "click->nested-form#remove"} do %>
+ Remove <%= @field.name.singularize %>
+ <% end %>
+ <%= render field.template_member.component_for_view(view).new(field: field.template_member, form:, view:) %>
+
+
+
+ <% field.members.each do |f| %>
+
+ <% 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| %>
+
+ <% messages.each do |m|%>
+ - <%= m %>
+ <% end %>
+
+ <% 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 = <
access_webhooks boolean ∗ |
add_owner boolean ∗ |
+ expires_at datetime |
hashed_key string ∗ |
index_rubygems boolean ∗ |
last_accessed_at datetime |
@@ -267,6 +268,35 @@ m_LogTicket [label = <
>];
+"m_OIDC::ApiKeyRole" [label = <
+|
+
+ access_policy jsonb ∗ |
+ api_key_permissions jsonb ∗ |
+ name string ∗ |
+ token string (32) ∗ U |
+
+>];
+"m_OIDC::IdToken" [label = <
+|
+
+>];
+"m_OIDC::Provider" [label = <
+|
+
+ configuration jsonb |
+ issuer text U |
+ jwks jsonb |
+
+>];
m_Ownership [label = <
@@ -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 @@
-