Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Soft-delete users #4376

Merged
merged 10 commits into from Jan 27, 2024
Merged

Soft-delete users #4376

merged 10 commits into from Jan 27, 2024

Conversation

segiddins
Copy link
Member

@segiddins segiddins commented Jan 22, 2024

So we can retain a record of actions taken by users even if they are deleted, for security auditing purposes

Part of #4360

Additional changes to avoid issues with the migrations & seeds by avoiding loading model classes before the migrations have finished

Copy link

codecov bot commented Jan 22, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Comparison is base (b9b0edf) 96.97% compared to head (5ccbf1b) 96.99%.
Report is 3 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4376      +/-   ##
==========================================
+ Coverage   96.97%   96.99%   +0.01%     
==========================================
  Files         336      336              
  Lines        7477     7514      +37     
==========================================
+ Hits         7251     7288      +37     
  Misses        226      226              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Member

@indirect indirect left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good! I'm a little bit worried about the possible security issue of a future feature where the developer doesn't know they have to use User.active, and just writes User.find(id).do_important_thing!. Is there some way we can communicate to future contributors (and future ourselves) that is needed?

Ideas that come to mind for me are:

  • a default scope of deleted_at IS NULL so you can't accidentally find deleted users
  • a default scope with a syntax error in it, so you are forced to choose active or inactive anytime you query the users table
  • some sort of rubocop rule, that rejects queries without a scope for deletion

Open to other ideas!

@simi
Copy link
Member

simi commented Jan 22, 2024

@indirect what about some kind of inheritance to make User behave as is until this PR? It would potentially help also with distributing methods in this long model across multiple places. Since for example login related code could be part of "active" User model only and vice versa. There is already User::WithPrivateFields model introducing this pattern. It would be just tricky to find out good name for "raw" user without this default scope.

class User < Raw::User
  default_scope ... # with deleted_at IS NULL
end

db/schema.rb Outdated Show resolved Hide resolved
@simi
Copy link
Member

simi commented Jan 22, 2024

@segiddins was there any blocker to use gem like https://github.com/jhawthorn/discard?

@indirect
Copy link
Member

30c239b fully addresses my worry, if that works for the rest of you.

@simi I think in this case the diff is so small I would prefer this over a gem? but I guess I will let @segiddins answer with his own reasoning.

@segiddins
Copy link
Member Author

I think not using a gem here is clearer, as we can control which associations get their dependent action executed

@segiddins segiddins marked this pull request as ready for review January 23, 2024 02:28
Copy link
Member

@martinemde martinemde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I support this change. I left some comments one a few particular pieces of code but nothing should block merge if you feel otherwise.

Comment on lines 13 to 19
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it a little strange to manage the associations ourselves like this instead of explicitly deleting what should be deleted here.

An argument in favor of the approach in this PR is that you could delete a user for real and it would cascade the delete correctly because you have all the dependent options set. But, do we ever do that?

I'm wondering if leaving the path open for a hard delete makes this unnecessarily complex. Reaching into active record reflections and handling it ourselves doesn't seem very forward compatible, and it seems to split the logic about what is actually happening here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me know if you prefer this?

test/integration/sign_in_test.rb Show resolved Hide resolved
Comment on lines 36 to 45
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")
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like clearing the user. This removes any potential personal data while keeping whatever important associations we need to preserve.

user.class.reflect_on_all_associations.each do |reflection|
next if reflection.through_reflection?

action = ASSOCIATIONS.fetch(reflection.name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the benefit of using these AR internals instead of calling each relation and call destroy_all/destroy one by one? There are entries in ASSOCIATIONS with nil value, those are skipped if I understand it well. What's point of keeping those in that list? Wouldn't be enough to just do explicit calls like following?

user.approved_ownership_requests.destroy_all
# ...
webauthn_verification.destroy

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well put. This is my preference also.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benefit is making sure we make a decision about each association, so when new associations are added they will be handled explicitly

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a given that the dependent options (which protect foreign keys constraints) should trigger a hard delete on user soft delete.

oidc_api_key_roles: nil,
pushed_versions: nil
}.freeze

def perform(user:)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect this logic to be part of model itself and in background job just got called something like user.soft_delete. Btw. would it make sense to rename this job to SoftDeleteUserJob?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this solves the concern about a split canonical information about deletes.

end

def destroy_ActiveRecord__Reflection__HasOneReflection(association, _reflection) # rubocop:disable Naming/MethodName
association.delete
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why delete here instead of destroy? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's how HasOneReflection#handle_dependency works

@simi
Copy link
Member

simi commented Jan 25, 2024

I think not using a gem here is clearer, as we can control which associations get their dependent action executed

@segiddins Discard gem doesn't handle associations, but provides "discard" callback you can handle your strategy on your own in model. That seems friendlier to developer than separate logic in job class (easy to miss). By doing so, it would be enough to create DiscardUserJob with user.discard body.

@simi
Copy link
Member

simi commented Jan 25, 2024

Ad migrations: if they doesn't work anymore as they are, I can squash them together.

@segiddins
Copy link
Member Author

@simi @martinemde is this more what you were envisioning?

Copy link
Member

@martinemde martinemde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. I really like this now. The discard gem reduced boilerplate too. Thanks for letting me pick on this PR that was already approved 🙇

app/models/user.rb Outdated Show resolved Hide resolved
Comment on lines +306 to +315
def destroy_associations_for_discard
ownerships.unscope(where: :confirmed_at).destroy_all
ownership_requests.update_all(status: :closed)
ownership_calls.unscope(where: :status).destroy_all
oidc_pending_trusted_publishers.destroy_all
subscriptions.destroy_all
web_hooks.destroy_all
webauthn_credentials.destroy_all
webauthn_verification&.destroy!
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is way better in my opinion. Absolutely easier to understand what will happen.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Member

@simi simi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your patience with my review @segiddins. This looks fantastic now!

Comment on lines +306 to +315
def destroy_associations_for_discard
ownerships.unscope(where: :confirmed_at).destroy_all
ownership_requests.update_all(status: :closed)
ownership_calls.unscope(where: :status).destroy_all
oidc_pending_trusted_publishers.destroy_all
subscriptions.destroy_all
web_hooks.destroy_all
webauthn_credentials.destroy_all
webauthn_verification&.destroy!
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@segiddins segiddins merged commit 91fc748 into master Jan 27, 2024
16 checks passed
@segiddins segiddins deleted the segiddins/soft-delete-users branch January 27, 2024 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants