Skip to content

Commit

Permalink
Soft-delete users
Browse files Browse the repository at this point in the history
So we can retain a record of actions taken by users even if they are deleted, for security auditing purposes
  • Loading branch information
segiddins committed Jan 22, 2024
1 parent edb231e commit ed737f5
Show file tree
Hide file tree
Showing 27 changed files with 206 additions and 98 deletions.
2 changes: 1 addition & 1 deletion app/avo/cards/users_metric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ class UsersMetric < Avo::Dashboards::MetricCard
self.cols = 2

def query
result User.count
result User.active.count
end
end
2 changes: 2 additions & 0 deletions app/avo/resources/user_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class UserResource < Avo::BaseResource
field :mail_fails, as: :number
field :blocked_email, as: :text

field :deleted_at, as: :date_time

tabs style: :pills do
tab "Auth" do
field :encrypted_password, as: :password, visible: ->(_) { false }
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/api/v1/owners_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def show
def create
return render_api_key_forbidden unless @api_key.can_add_owner?

owner = User.find_by_name(email_param)
owner = User.active.find_by_name(email_param)
if owner
ownership = @rubygem.ownerships.new(user: owner, authorizer: @api_key.user)
if ownership.save
Expand Down Expand Up @@ -51,7 +51,7 @@ def destroy
end

def gems
user = User.find_by_slug(params[:handle])
user = User.active.find_by_slug(params[:handle])
if user
rubygems = user.rubygems.with_versions
respond_to do |format|
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/profiles_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class Api::V1::ProfilesController < Api::BaseController
def show
@user = User.find_by_slug!(params[:id])
@user = User.active.find_by_slug!(params[:id])
respond_to do |format|
format.json { render json: @user }
format.yaml { render yaml: @user }
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/concerns/webauthn_verifiable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def webauthn_credential_scope
if @user.present?
@user.webauthn_credentials
else
User.find_by(webauthn_id: @credential.user_handle)&.webauthn_credentials || WebauthnCredential.none
User.active.find_by(webauthn_id: @credential.user_handle)&.webauthn_credentials || WebauthnCredential.none
end
end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/email_confirmations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def find_user_for_create
end

def validate_confirmation_token
@user = User.find_by(confirmation_token: token_params)
@user = User.active.find_by(confirmation_token: token_params)
redirect_to root_path, alert: t("failure_when_forbidden") unless @user&.valid_confirmation_token?
end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/owners_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def index
end

def create
owner = User.find_by_name(handle_params)
owner = User.active.find_by_name(handle_params)
ownership = @rubygem.ownerships.new(user: owner, authorizer: current_user)
if ownership.save
OwnersMailer.ownership_confirmation(ownership).deliver_later
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/profiles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class ProfilesController < ApplicationController
before_action :set_cache_headers, only: :edit

def show
@user = User.find_by_slug!(params[:id])
@user = User.active.find_by_slug!(params[:id])
rubygems = @user.rubygems_downloaded
@rubygems = rubygems.slice!(0, 10)
@extra_rubygems = rubygems
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def create
end

def webauthn_create
@user = User.find(session[:mfa_user])
@user = User.active.find(session[:mfa_user])

unless session_active?
login_failure(t("multifactor_auths.session_expired"))
Expand All @@ -51,7 +51,7 @@ def webauthn_full_create
end

def otp_create
@user = User.find(session[:mfa_user])
@user = User.active.find(session[:mfa_user])

if login_conditions_met?
record_mfa_login_duration(mfa_type: "otp")
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/stats_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class StatsController < ApplicationController

def index
@number_of_gems = Rubygem.total_count
@number_of_users = User.count
@number_of_users = User.active.count
@number_of_downloads = GemDownload.total_count
@most_downloaded = Rubygem.by_downloads.includes(:gem_download).page(@page).per(Gemcutter::STATS_PER_PAGE)
@most_downloaded_count = GemDownload.most_downloaded_gem_count
Expand Down
20 changes: 0 additions & 20 deletions app/jobs/delete_user.rb

This file was deleted.

39 changes: 31 additions & 8 deletions app/jobs/delete_user_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,36 @@ class DeleteUserJob < ApplicationJob

def perform(user:)
email = user.email
user.destroy!
rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::NotNullViolation, ActiveRecord::DeleteRestrictionError => e
# Catch the exception so we can log it, otherwise using `destroy` would give
# us no hint as to why the deletion failed.
Rails.error.report(e, context: { user:, email: }, handled: true)
Mailer.deletion_failed(email).deliver_later
else
Mailer.deletion_complete(email).deliver_later
return if user.reload.deleted_at?

begin
user.transaction do
user.yank_gems
user.api_keys.expire_all!
user.class.reflect_on_all_associations.each do |association|
next if association.name == :api_keys
case association.options[:dependent]
when :destroy, :destroy_async
user.association(association.name).handle_dependency
end
end
user.update!(
deleted_at: Time.current, email: "deleted+#{user.id}@rubygems.org",
handle: nil, email_confirmed: false,
unconfirmed_email: nil, blocked_email: nil,
api_key: nil, confirmation_token: nil, remember_token: nil,
twitter_username: nil, webauthn_id: nil, full_name: nil,
totp_seed: nil, mfa_hashed_recovery_codes: nil,
mfa_level: :disabled,
password: SecureRandom.hex(20).encode("UTF-8")
)
Mailer.deletion_complete(email).deliver_later
end
rescue ActiveRecord::ActiveRecordError => e
# Catch the exception so we can log it, otherwise using `destroy` would give
# us no hint as to why the deletion failed.
Rails.error.report(e, context: { user:, email: }, handled: true)
Mailer.deletion_failed(email).deliver_later
end
end
end
13 changes: 0 additions & 13 deletions app/jobs/email_confirmation_mailer.rb

This file was deleted.

9 changes: 0 additions & 9 deletions app/jobs/email_reset_mailer.rb

This file was deleted.

9 changes: 5 additions & 4 deletions app/jobs/mfa_usage_stats_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ class MfaUsageStatsJob < ApplicationJob
queue_as "stats"

def perform
non_mfa_users = User.where(totp_seed: nil).where.not(id: WebauthnCredential.select(:user_id)).count
totp_only_users = User.where.not(totp_seed: nil).where.not(id: WebauthnCredential.select(:user_id)).count
webauthn_only_users = User.where(totp_seed: nil).where(id: WebauthnCredential.select(:user_id)).count
webauthn_and_totp_users = User.where.not(totp_seed: nil).where(id: WebauthnCredential.select(:user_id)).count
users = User.active
non_mfa_users = users.where(totp_seed: nil).where.not(id: WebauthnCredential.select(:user_id)).count
totp_only_users = users.where.not(totp_seed: nil).where.not(id: WebauthnCredential.select(:user_id)).count
webauthn_only_users = users.where(totp_seed: nil).where(id: WebauthnCredential.select(:user_id)).count
webauthn_and_totp_users = users.where.not(totp_seed: nil).where(id: WebauthnCredential.select(:user_id)).count

StatsD.gauge("mfa_usage_stats.non_mfa_users", non_mfa_users)
StatsD.gauge("mfa_usage_stats.totp_only_users", totp_only_users)
Expand Down
20 changes: 12 additions & 8 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ class User < ApplicationRecord
before_create :_generate_confirmation_token_no_reset_unconfirmed_email
before_destroy :yank_gems

scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }

has_many :ownerships, -> { confirmed }, dependent: :destroy, inverse_of: :user

has_many :rubygems, through: :ownerships, source: :rubygem
Expand Down Expand Up @@ -67,7 +70,8 @@ def self.authenticate(who, password)
# to UTF-8.
who = who.encode(Encoding::UTF_8)

user = find_by(email: who.downcase) || find_by(handle: who)
scope = active
user = scope.find_by(email: who.downcase) || scope.find_by(handle: who)
user if user&.authenticated?(password)
rescue BCrypt::Errors::InvalidHash, Encoding::UndefinedConversionError
nil
Expand Down Expand Up @@ -257,6 +261,13 @@ def ld_context
)
end

def yank_gems
versions_to_yank = only_owner_gems.map(&:versions).flatten
versions_to_yank.each do |v|
deletions.create(version: v)
end
end

private

def update_email
Expand All @@ -272,13 +283,6 @@ def unconfirmed_email_exists?
User.exists?(email: unconfirmed_email)
end

def yank_gems
versions_to_yank = only_owner_gems.map(&:versions).flatten
versions_to_yank.each do |v|
deletions.create(version: v)
end
end

def toxic_email_domain
return unless (domain = email.split("@").last)
toxic_domains_path = Pathname.new(Gemcutter::Application.config.toxic_domains_filepath)
Expand Down
5 changes: 4 additions & 1 deletion app/models/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ class Version < ApplicationRecord # rubocop:disable Metrics/ClassLength
has_many :dependencies, -> { order("rubygems.name ASC").includes(:rubygem) }, dependent: :destroy, inverse_of: "version"
has_many :audits, as: :auditable, inverse_of: :auditable, dependent: :nullify
has_one :gem_download, inverse_of: :version, dependent: :destroy
belongs_to :pusher, class_name: "User", inverse_of: false, optional: true
belongs_to :pusher, class_name: "User", inverse_of: :pushed_versions, optional: true
belongs_to :pusher_api_key, class_name: "ApiKey", inverse_of: :pushed_versions, optional: true
has_one :deletion, dependent: :delete, inverse_of: :version, required: false
has_one :yanker, through: :deletion, source: :user, inverse_of: :yanked_versions, required: false

belongs_to :active_pusher, -> { active }, class_name: "User", inverse_of: :pushed_versions, primary_key: :pusher_id, optional: true
has_one :active_yanker, -> { active }, through: :deletion, source: :user, inverse_of: :yanked_versions, required: false

before_validation :set_canonical_number, if: :number_changed?
before_validation :full_nameify!
before_validation :gem_full_nameify!
Expand Down
8 changes: 4 additions & 4 deletions app/views/rubygems/_gem_members.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
<% end %>
<% end %>
<% if latest_version.pusher.present? %>
<% if latest_version.active_pusher.present? %>
<h3 class="t-list__heading"><%= t '.pushed_by' %>:</h3>
<div class="gem__users">
<%= link_to_user(latest_version.pusher) %>
<%= link_to_user(latest_version.active_pusher) %>
</div>
<% elsif latest_version.pusher_api_key&.owner.present? %>
<h3 class="t-list__heading"><%= t '.pushed_by' %>:</h3>
Expand All @@ -39,10 +39,10 @@
</div>
<% end %>
<% if latest_version.yanker.present? %>
<% if latest_version.active_yanker.present? %>
<h3 class="t-list__heading"><%= t '.yanked_by' %>:</h3>
<div class="gem__users">
<%= link_to_user(latest_version.yanker) %>
<%= link_to_user(latest_version.active_yanker) %>
</div>
<% end %>
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20240120010424_add_deleted_at_to_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddDeletedAtToUser < ActiveRecord::Migration[7.1]
def change
add_column :users, :deleted_at, :datetime
end
end
28 changes: 27 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.1].define(version: 2024_01_10_052615) do
ActiveRecord::Schema[7.1].define(version: 2024_01_20_010424) do
# These are extensions that must be enabled in order to support this database
enable_extension "hstore"
enable_extension "pgcrypto"
Expand Down Expand Up @@ -99,6 +99,19 @@
t.index ["version_id"], name: "index_dependencies_on_version_id"
end

create_table "events_user_events", force: :cascade do |t|
t.string "tag", null: false
t.string "trace_id"
t.bigint "user_id", null: false
t.bigint "ip_address_id"
t.jsonb "additional"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["ip_address_id"], name: "index_events_user_events_on_ip_address_id"
t.index ["tag"], name: "index_events_user_events_on_tag"
t.index ["user_id"], name: "index_events_user_events_on_user_id"
end

create_table "gem_downloads", id: :serial, force: :cascade do |t|
t.integer "rubygem_id", null: false
t.integer "version_id", null: false
Expand Down Expand Up @@ -200,6 +213,16 @@
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end

create_table "ip_addresses", force: :cascade do |t|
t.inet "ip_address", null: false
t.text "hashed_ip_address", null: false
t.jsonb "geoip_info"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["hashed_ip_address"], name: "index_ip_addresses_on_hashed_ip_address", unique: true
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end

create_table "link_verifications", force: :cascade do |t|
t.string "linkable_type", null: false
t.bigint "linkable_id", null: false
Expand Down Expand Up @@ -427,6 +450,7 @@
t.string "totp_seed"
t.string "mfa_hashed_recovery_codes", default: [], array: true
t.boolean "public_email", default: false, null: false
t.datetime "deleted_at"
t.index ["email"], name: "index_users_on_email"
t.index ["handle"], name: "index_users_on_handle"
t.index ["id", "confirmation_token"], name: "index_users_on_id_and_confirmation_token"
Expand Down Expand Up @@ -524,6 +548,8 @@
t.index ["user_id"], name: "index_webauthn_verifications_on_user_id", unique: true
end

add_foreign_key "events_user_events", "ip_addresses"
add_foreign_key "events_user_events", "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"
Expand Down

0 comments on commit ed737f5

Please sign in to comment.