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
Soft-delete users #4376
Conversation
Codecov ReportAll modified and coverable lines are covered by tests ✅
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. |
ed737f5
to
c7af718
Compare
There was a problem hiding this 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
orinactive
anytime you query the users table - some sort of rubocop rule, that rejects queries without a scope for deletion
Open to other ideas!
@indirect what about some kind of inheritance to make class User < Raw::User
default_scope ... # with deleted_at IS NULL
end |
@segiddins was there any blocker to use gem like https://github.com/jhawthorn/discard? |
c7af718
to
30c239b
Compare
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. |
30c239b
to
add3dff
Compare
I think not using a gem here is clearer, as we can control which associations get their |
There was a problem hiding this 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.
app/jobs/delete_user_job.rb
Outdated
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
add3dff
to
e504ded
Compare
app/jobs/delete_user_job.rb
Outdated
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") | ||
) |
There was a problem hiding this comment.
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.
18c3d90
to
71c824f
Compare
app/jobs/delete_user_job.rb
Outdated
user.class.reflect_on_all_associations.each do |reflection| | ||
next if reflection.through_reflection? | ||
|
||
action = ASSOCIATIONS.fetch(reflection.name) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:) |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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.
app/jobs/delete_user_job.rb
Outdated
end | ||
|
||
def destroy_ActiveRecord__Reflection__HasOneReflection(association, _reflection) # rubocop:disable Naming/MethodName | ||
association.delete |
There was a problem hiding this comment.
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? 🤔
There was a problem hiding this comment.
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
@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 |
Ad migrations: if they doesn't work anymore as they are, I can squash them together. |
So we can retain a record of actions taken by users even if they are deleted, for security auditing purposes
71c824f
to
d9c430a
Compare
@simi @martinemde is this more what you were envisioning? |
d9c430a
to
34ac8aa
Compare
There was a problem hiding this 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 🙇
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this 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!
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
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