-
Notifications
You must be signed in to change notification settings - Fork 35
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
Move event log recording in UpdateUser service into callbacks on User model #2623
Move event log recording in UpdateUser service into callbacks on User model #2623
Conversation
f0eb17a
to
76c4a55
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 great 👍
@chrisroos Has identified some possible flaws in this which need investigating before this is merged:
|
I think we might be able to use cc @chrisroos |
The `Current` class inherits from `ActiveSupport::CurrentAttributes` [1] which means that its singleton attribute accessors are "thread-isolated". This means that we can set them in a new `before_action` in `ApplicationController` and then make use of them in model code without needing access to a controller. I'm hoping to use this to simplify calls to `EventLog` methods and ultimately move a bunch of functionality in `UserUpdate#call` into model callbacks. [1]: https://api.rubyonrails.org/v7.0.8/classes/ActiveSupport/CurrentAttributes.html
76c4a55
to
53b540e
Compare
I've attempted to address this in 32ad5d5. Let me know what you think. |
@@ -7,7 +7,9 @@ def process(csv_file) | |||
file.close | |||
|
|||
begin | |||
DataHygiene::BulkOrganisationUpdater.call(file.path, logger: Rails.logger) | |||
with_current(user: create(:admin_user)) do | |||
DataHygiene::BulkOrganisationUpdater.call(file.path, logger: Rails.logger) |
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.
Does this imply that we need to set Current.user
in the data_hygiene:bulk_update_organisation
Rake task too? I think it does otherwise the behaviour of EventLog.record_email_change
will be slightly different after this change (it'll record an EMAIL_CHANGED
event instead of an EMAIL_CHANGE_INITIATED
event).
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.
Good catch. Although the original implementation seems a bit weird. I wonder if it would be clearer to have two methods on EventLog
: one for recording an EMAIL_CHANGED
event and one for recording an EMAIL_CHANGE_INITIATED
event...? What do you think...?
Separately, I've been experimenting with setting Current.user
to a sensible value for rake tasks, use of the rails console, and for background jobs. I wonder if this might be a better way to solve this problem (because Current.user
would then always be set.
@@ -7,6 +7,7 @@ class UserUpdateTest < ActionView::TestCase | |||
setup do | |||
@current_user = create(:superadmin_user) | |||
@affected_user = create(:user) | |||
@affected_user.name = "different-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.
Is this necessary to get past the return if previous_changes.none?
clause?
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.
Yes, I think that's probably correct. Do you think I should do something to make that clearer?
@@ -463,4 +464,11 @@ def strip_whitespace_from_name | |||
def update_password_changed | |||
self.password_changed_at = Time.current if (new_record? || encrypted_password_changed?) && !password_changed_at_changed? | |||
end | |||
|
|||
def record_update |
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.
Did you consider adding unit tests for this method? Or is the behaviour (particularly the guard clauses) tested elsewhere?
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 think at this point I was relying on the unit test(s) in UserUpdateTest
. However, it might well make sense to move those to UserTest
in this commit (or possibly a subsequent commit).
@@ -471,4 +472,11 @@ def record_update | |||
|
|||
EventLog.record_event(self, EventLog::ACCOUNT_UPDATED, initiator: true, ip_address: true) | |||
end | |||
|
|||
def record_role_change |
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.
As with User#record_update
, I wonder whether it's worth adding unit tests for this method?
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.
Agreed 👍
|
||
def record_organisation_change | ||
return if Current.user.blank? | ||
return unless organisation_id_previously_changed? |
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.
Out of interest, what's the difference between #organisation_id_previously_changed?
and #organisation_id_changed?
?
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.
Out of interest, what's the difference between
#organisation_id_previously_changed?
and#organisation_id_changed?
?
From what I remember, the former includes changes that have been persisted but the latter does not. Since User#record_orgnisation_change
is being called in an after_update
callback, we need the version which includes changes that have been persisted. I'm afraid I can't remember why I used an after_update
callback rather than a before_update
callback!
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.
Following up on this, it looks as if I may have followed the precedent set by UserUpdate#call
which seems to persist the changes first and only then record the various events.
|
||
organisations = organisation_id_previous_change.map do |organisation_id| | ||
Organisation.find_by(id: organisation_id)&.name || Organisation::NONE | ||
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 found this slightly confusing as I had to look up that #organisation_id_previous_change
returned an array of the from
and to
values, and also that the variable is named organisations
but it holds an array of organisation names.
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 that this could be made clearer 👍
|
||
def record_2sv_mandated | ||
return if Current.user.blank? | ||
return unless require_2sv && require_2sv_previously_changed? |
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 know that you've copied this from the previous implementation in UserUpdate#record_2sv_mandated
but I do wonder whether this should be using User#require_2sv?
instead of #require_2sv
.
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.
Confusingly, it looks as if User#require_2sv?
is not simply a reflection of the value in the users.require_2sv
column, but rather it includes some other logic involving an organisation setting and any exemption. So I don't think we should use User#require_2sv?
here.
Having said all that, it feels as if we should change the name of User#require_2sv?
or the underlying column to make things more idiomatic and less surprising.
32ad5d5
to
7439bb7
Compare
I think this makes the tests more robust. Also it'll make some upcoming changes easier.
Unfortunately a bunch of the tests were relying on the `initiator` defaulting to the user being updated, so for the moment I've replicated that in the new implementation of `EventLog.record_email_change`. However, I plan to remove that in a subsequent commit.
Now that we're using `Current.user` to obtain the `initiator`, there's no need for the default - `Current.user` should always be set. This means we have to explicitly set `Current.user` in some tests to avoid validation errors due to `EventLog#initiator_id` being missing.
Previously I found it confusing that the `previous_organisation` & `new_organisation` arguments were actually the names of the organisations rather than the organisations themselves. Renaming them to `previous_organisation_name` & `new_organisation_name` respectively makes things less confusing.
The `ip_address` option for this method is optional and not all callsites were setting it. This means we only record the current user's IP address in some cases. To this end I've repurposed the `ip_address` option to be a boolean value indicating whether or not we want to record the user's IP address.
The `initiator` option for this method is optional and not all callsites were setting it. This means we only record the initiating user in some cases. To this end I've repurposed the `initiator` option to be a boolean value indicating whether or not we want to record the initiating user.
This is now called *automatically* via an `after_update` callback which means it's called more reliably and more frequently than `UserUpdate#record_update` was called. The `ActiveModel::Dirty#previous_changes` guard condition avoids recording events when none of the user's attributes have changed which would be pretty pointless. The `Current.user` guard condition is necessary, because `LogEvent::ACCOUNT_UPDATED` has `require_initiator` set to true and so would trigger a validation error if `Current.user` was not set. I've had to add a few stubs for extra `LogEvent::ACCOUNT_UPDATED` event log records which reflect a slight change in behaviour. However, I don't think these extra even log records are necessarily a bad thing! The advantage of recording the event in a callback like this is that we don't need to remember to use `UserUpdate#call` to make any changes to a user. This in turn means that we will now be recording events when an API user's attributes are updated. which we weren't previously because the `ApiUsersController` wasn't calling `UserUpdate#call`.
This is now called *automatically* via an `after_update` callback which means it's called more reliably than `UserUpdate#record_role_change` was called. I've changed the implementation very slightly to make use of other magic methods provided by `ActiveModel::Dirty#previous_changes`. I don't believe any of these changes will have changed the behaviour. The `Current.user` guard condition is necessary, because `LogEvent::ROLE_CHANGED` has `require_initiator` set to true and so would trigger a validation error if `Current.user` was not set. The advantage of recording the event in a callback like this is that we don't need to remember to use `UserUpdate#call` to make any changes to a user and we may now catch some places where a user's role was being updated but no event was being recorded.
This is now called *automatically* via an `after_update` callback which means it's called more reliably than `UserUpdate#record_organisation_change` was called. I've changed the implementation very slightly to make use of other magic methods provided by `ActiveModel::Dirty#previous_changes`. I don't believe any of these changes will have changed the behaviour. The `Current.user` guard condition is necessary, because `LogEvent::ORGANISATION_CHANGED` has `require_initiator` set to true and so would trigger a validation error if `Current.user` was not set. The advantage of recording the event in a callback like this is that we don't need to remember to use `UserUpdate#call` to make any changes to a user and we may now catch some places where a user's organisation was being updated but no event was being recorded.
This is now called *automatically* via an `after_update` callback which means it's called more reliably than `UserUpdate#record_2sv_exemption_removed` was called. I've changed the implementation very slightly to make use of other magic methods provided by `ActiveModel::Dirty#previous_changes`. I don't believe any of these changes will have changed the behaviour. The `Current.user` guard condition is necessary, because `LogEvent::TWO_STEP_EXEMPTION_REMOVED` has `require_initiator` set to true and so would trigger a validation error if `Current.user` was not set. The advantage of recording the event in a callback like this is that we don't need to remember to use `UserUpdate#call` to make any changes to a user and we may now catch some places where a user's 2SV was being removed but no event was being recorded.
This is now called *automatically* via an `after_update` callback which means it's called more reliably than `UserUpdate#record_2sv_mandated` was called. I've changed the implementation very slightly to make use of other magic methods provided by `ActiveModel::Dirty#previous_changes`. I don't believe any of these changes will have changed the behaviour. The `Current.user` guard condition is necessary, because `LogEvent::TWO_STEP_MANDATED` has `require_initiator` set to true and so would trigger a validation error if `Current.user` was not set. The advantage of recording the event in a callback like this is that we don't need to remember to use `UserUpdate#call` to make any changes to a user and we may now catch some places where 2SV was being mandated for a user but no event was being recorded.
Previously, if a user was signed in or out in the *middle* of an action, the value of `Current.user` was not updated. This makes use of `Warden::Manager.after_set_user` [1] and `Warden::Manager.before_logout` [2] hooks to update `Current.user`. I'm using `Devise::Controllers::SignInOut#sign_in` [3] & `Devise::Controllers::SignInOut#sign_out` within the tests, because I believe that's what you're supposed to use when using `Devise` and indeed it looks like that's what we're using in e.g. `ConfirmationsController`, `Devise::TwoStepVerificationController` & `Devise::TwoStepVerificationSessionController`. I've added the `Warden::Manager` hooks within `Devise::Hooks::SetCurrentUser` and required it from the top of `app/controllers/application_controller.rb` to be consistent what we're already doing with `Devise::Hooks::TwoStepVerification`, although it seems a bit odd/non-idiomatic to execute some code via a call to `require`. Also it strikes me as slightly odd that the hooks are in a top-level `Devise` module despite being associated with `Warden`, although I suppose `Warden` is only there because it's a dependency of `Devise`. [1]: https://www.rubydoc.info/gems/warden/1.2.9/Warden/Hooks#after_set_user-instance_method [2]: https://www.rubydoc.info/gems/warden/1.2.9/Warden/Hooks#before_logout-instance_method [3]: https://www.rubydoc.info/github/plataformatec/devise/Devise%2FControllers%2FSignInOut:sign_in [4]: https://www.rubydoc.info/github/plataformatec/devise/Devise%2FControllers%2FSignInOut:sign_out
7439bb7
to
9114bdb
Compare
I'm planning to create a bunch of "reference" users like this and it seems clearer to make them class methods on `ApiUser`.
TODO: * Ensure that `Current.user` is being set in the same thread that the job's `#perform` method is called in. * Add tests
TODO: * Add tests
TODO: * Add tests
I'm assuming that most/all of the calls to |
While I still think this is a sensible idea, having discussed the PR with @chrisroos today, I've decided that I'm not going to have enough time before I finish this week to get this into a state where the risk of causing a problem is sufficiently low to justify the benefits of the change, especially given that a bunch of the stuff mentioned in the "Further work" section of the PR description would also need to be done to really see all the benefits. So I'm going to close the PR and hope that someone has the time to come back to it at some point the in the future! 😅 |
The new
Current
class inherits fromActiveSupport::CurrentAttributes
which means that its singleton attribute accessors are "thread-isolated". This means that we can setCurrent.user
&Current.user_ip
in a newbefore_action
inApplicationController
and then make use of them in model code without needing access to a controller.I've used this change to simplify calls to
EventLog
methods and move a bunch of functionality inUserUpdate#call
into model callbacks. I think we could take this further, but I wanted to get these changes merged now that I'm reasonably confident this is a sensible approach.Further work
EventLog.record_event
require the initiating user or the user's IP address. This means that currently we have to passinitiator: true
and/orip_address: true
options. I strongly suspect the reason why some calls don't require the initiating user or the user's IP address is because it was awkward/impossible to obtain them at the callsite. However, now that they are available from theCurrent
model, it would simplify the code a lot if we just always recorded them. And I can't see why this would be a bad idea.UserUpdate#call
(e.g. sending of email notifications) that could me moved to callbacks on theUser
model. This would simplifyUserUpdate#call
and ultimately make it redundant. And I think it would make the code a lot more resilient, because developers wouldn't need to keep remembering to useUserUpdate
whenever they need to make a change to a user. There are already places where this has been missed. AlsoUserUpdate
is the only "service" in the app, so it's a bit of an outlier and it would be good to make the code more idiomatic.EventLog.record_event
or sending of email notifications in controllers which could move into callbacks on other models.