Skip to content

Commit

Permalink
Add avo MFA reset admin action & view of audit entries (#3426)
Browse files Browse the repository at this point in the history
* Add avo MFA reset admin action & view of audit entries

* Add a base action with more robust audit change tracking

* Add test that covers the reset MFA action

* Make gravatar smaller on show view

* Sort fields in record diff

* Make the audited changes the focus of the audit show component

* Delete unused method

* Add a test for base action error handling

To goose code coverage metrics
  • Loading branch information
segiddins committed Feb 14, 2023
1 parent 9659578 commit afe6978
Show file tree
Hide file tree
Showing 22 changed files with 500 additions and 1 deletion.
103 changes: 103 additions & 0 deletions app/avo/actions/base_action.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
class BaseAction < Avo::BaseAction
field :comment, as: :textarea, required: true,
help: "A comment explaining why this action was taken.<br>Will be saved in the audit log.<br>Must be more than 10 characters."

class ActionHandler
include ActiveSupport::Callbacks
define_callbacks :handle, terminator: lambda { |target, result_lambda|
result_lambda.call
target.errored?
}

def initialize( # rubocop:disable Metrics/ParameterLists
fields:, current_user:, arguments:, resource:, action:, models: nil
)
@models = models
@fields = fields
@current_user = current_user
@arguments = arguments
@resource = resource

@action = action
end

attr_reader :models, :fields, :current_user, :arguments, :resource

delegate :error, :avo, :keep_modal_open, :redirect_to, :inform,
to: :@action

set_callback :handle, :before do
error "Must supply a sufficiently detailed comment" unless fields[:comment].presence&.then { _1.length >= 10 }
end

set_callback :handle, :around, lambda { |_, block|
begin
block.call
rescue StandardError => e
error e.message.truncate(300)
end
}

def do_handle
run_callbacks :handle do
handle
end
keep_modal_open if errored?
end

def errored?
@action.response[:messages].any? { _1[:type] == :error }
end

def in_audited_transaction(&)
User.transaction do
changed_records = {}
ActiveSupport::Notifications.subscribed(proc do |_name, _started, _finished, _unique_id, data|
data[:connection].transaction_manager.current_transaction.records.uniq(&:__id__).each do |record|
(changed_records[record] ||= {}).merge!(record.changes_to_save) do |_key, (old, _), (_, new)|
[old, new]
end
end
end, "sql.active_record", &)

audited_changed_records = changed_records.to_h do |record, changes|
[
record.to_global_id.uri,
{ changes:, unchanged: record.attributes.except(*changes.keys) }
]
end

audit = Audit.create!(
admin_github_user: current_user,
auditable: @current_model,
action: @action.name,
comment: fields[:comment],
audited_changes: {
records: audited_changed_records,
fields: fields.except(:comment),
arguments: arguments,
models: models.map { _1.to_global_id.uri }
}
)
redirect_to avo.resources_audit_path(audit)
end
end

def handle
models.each do |model|
@current_model = model
in_audited_transaction do
handle_model(model)
end
end
@current_model = nil
end
end

def handle(**args)
"#{self.class}::ActionHandler"
.constantize
.new(**args, arguments:, action: self)
.do_handle
end
end
20 changes: 20 additions & 0 deletions app/avo/actions/reset_user_2fa.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class ResetUser2fa < BaseAction
self.name = "Reset User 2FA"
self.visible = lambda {
current_user.team_member?("rubygems-org") && view == :show
}

self.message = lambda {
"Are you sure you would like to disable MFA and reset the password for #{record.handle} #{record.email}?"
}

self.confirm_button_label = "Reset MFA"

class ActionHandler < ActionHandler
def handle_model(user)
user.disable_mfa!
user.password = SecureRandom.hex(20).encode("UTF-8")
user.save!
end
end
end
2 changes: 2 additions & 0 deletions app/avo/fields/audited_changes_field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class AuditedChangesField < Avo::Fields::BaseField
end
12 changes: 12 additions & 0 deletions app/avo/fields/json_viewer_field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class JsonViewerField < Avo::Fields::BaseField
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
end

attr_reader :height, :theme, :tab_size, :indent_with_tabs, :line_wrapping
end
36 changes: 36 additions & 0 deletions app/avo/resources/audit_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class AuditResource < Avo::BaseResource
self.title = :id
self.includes = %i[
admin_github_user
auditable
]

field :action, as: :text

sidebar do
field :admin_github_user, as: :belongs_to
field :created_at, as: :date_time
field :comment, as: :text

field :auditable, as: :belongs_to,
polymorphic_as: :auditable,
types: [::User],
name: "Edited Record"

heading "Action Details"

field :audited_changes_arguments, as: :json_viewer, only_on: :show do |model|
model.audited_changes["arguments"]
end
field :audited_changes_fields, as: :json_viewer, only_on: :show do |model|
model.audited_changes["fields"]
end
field :audited_changes_models, as: :text, as_html: true, only_on: :show do
model.audited_changes["models"]
end

field :id, as: :id
end

field :audited_changes, as: :audited_changes
end
5 changes: 5 additions & 0 deletions app/avo/resources/user_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ class UserResource < Avo::BaseResource
scope.where("email LIKE ? OR handle LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%")
}

action ResetUser2fa

field :id, as: :id
# Fields generated from the model
field :email, as: :text
Expand All @@ -28,6 +30,7 @@ class UserResource < Avo::BaseResource

tabs style: :pills do
tab "Auth" do
field :encrypted_password, as: :password, visible: ->(_) { false }
field :mfa_seed, as: :text, visible: ->(_) { false }
field :mfa_level, as: :select, enum: ::User.mfa_levels
field :mfa_recovery_codes, as: :text, visible: ->(_) { false }
Expand All @@ -51,5 +54,7 @@ class UserResource < Avo::BaseResource
field :api_keys, as: :has_many, name: "API Keys"
field :ownership_calls, as: :has_many
field :ownership_requests, as: :has_many

field :audits, as: :has_many
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<%= render Avo::PanelComponent.new(title: title_link, classes: %w[w-full]) do |c| %>
<% c.body do %>
<% next unless authorized? %>

<div class="divide-y divide-dashed">
<% each_field do |type, component| %>
<%= tag.div class: change_type_row_classes(type) do %>
<span class="px-1 w-4"><%= change_type_icon type %></span>
<%= render component %>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
75 changes: 75 additions & 0 deletions app/components/avo/audited_changes_record_diff/show_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

class Avo::AuditedChangesRecordDiff::ShowComponent < ViewComponent::Base
def initialize(gid:, changes:, unchanged:, user:)
super
@gid = gid
@changes = changes
@unchanged = unchanged
@user = user

model = GlobalID::Locator.locate(gid)
@resource = Avo::App.get_resource_by_name(model.class.name).hydrate(model:, user:)

@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)))
end

attr_reader :gid, :changes, :unchanged, :user, :resource, :old_resource, :new_resource

def sorted_fields
@resource.fields
.reject { _1.is_a?(Avo::Fields::HasBaseField) }
.sort_by.with_index { |f, i| [changes.key?(f.id.to_s) ? -1 : 1, i] }
end

def each_field
sorted_fields.each do |field|
unless field.visible?
if changes.key?(field.id.to_s)
# dummy field to avoid ever printing out the contents... we just want the label
yield :changed, Avo::Fields::BooleanField::ShowComponent.new(field: field)
end
next
end

if changes.key?(field.id.to_s)
yield :new, field.component_for_view(:show).new(field: field.hydrate(model: new_resource.model), resource: new_resource)
yield :old, field.component_for_view(:show).new(field: field.hydrate(model: old_resource.model), resource: old_resource)
else
yield :unchanged, field.component_for_view(:show).new(field: field.hydrate(model: new_resource.model), resource: new_resource)
end
end
end

def authorized?
Pundit.policy!(user, resource.model).avo_show?
end

def title_link
link_to(resource.model_title, resource.record_path)
end

def change_type_icon(type)
case type
when :changed
helpers.svg("arrows-right-left", class: %w[h-4])
when :new
helpers.svg("forward", class: %w[h-4])
when :old
helpers.svg("backward", class: %w[h-4])
end
end

def change_type_row_classes(type)
case type
when :changed
%w[bg-orange-400]
when :new
%w[bg-green-500]
when :old
%w[bg-red-400]
else []
end + %w[flex flex-row items-baseline]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<%= field_wrapper **field_wrapper_args, full_width: true do %>
<% records&.each do |gid, changes, unchanged| %>
<%= render Avo::AuditedChangesRecordDiff::ShowComponent.new(
gid:,
changes:,
unchanged:,
user: resource.user,
) %>
<% end %>
<% end %>
11 changes: 11 additions & 0 deletions app/components/avo/fields/audited_changes_field/show_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class Avo::Fields::AuditedChangesField::ShowComponent < Avo::Fields::ShowComponent
def records
field.value["records"]&.map do |gid, body|
changes, unchanged = body.values_at("changes", "unchanged")

[gid, changes, unchanged]
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<%= field_wrapper **field_wrapper_args do %>
<%= render Avo::Fields::Common::GravatarViewerComponent.new(
md5: @field.md5,
default: @field.default,
size: 144,
)
%>
<% end %>
4 changes: 4 additions & 0 deletions app/components/avo/fields/gravatar_field/show_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class Avo::Fields::GravatarField::ShowComponent < Avo::Fields::ShowComponent
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<%= field_wrapper **field_wrapper_args, full_width: true do %>
<div data-controller="code-field" style="--height: <%= @field.height %>">
<%= 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,
}
%>
</div>
<% end %>
7 changes: 7 additions & 0 deletions app/components/avo/fields/json_viewer_field/show_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class Avo::Fields::JsonViewerField::ShowComponent < Avo::Fields::ShowComponent
def pretty_json
JSON.pretty_generate(@field.value)
end
end
4 changes: 4 additions & 0 deletions app/controllers/avo/audits_controller.rb
Original file line number Diff line number Diff line change
@@ -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::AuditsController < Avo::ResourcesController
end
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class User < ApplicationRecord
has_many :ownership_calls, -> { opened }, dependent: :destroy, inverse_of: :user
has_many :ownership_requests, -> { opened }, dependent: :destroy, inverse_of: :user

has_many :audits, as: :auditable, dependent: :nullify

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

Expand Down
20 changes: 20 additions & 0 deletions app/policies/audit_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class AuditPolicy < ApplicationPolicy
class Scope < Scope
# NOTE: Be explicit about which records you allow access to!
def resolve
if rubygems_org_admin?
scope.all
else
scope.where(admin_github_user: current_user)
end
end
end

def avo_index?
true
end

def avo_show?
true
end
end
Loading

0 comments on commit afe6978

Please sign in to comment.