feat: Add account merging mechanics to auth module#4752
Conversation
marcelomendoncasoares
left a comment
There was a problem hiding this comment.
Great work with this next piece of the anonymous auth feature, @craiglabenz! I have focused this review more on the API and design decisions. On a next review after all the discussions I'll get to the tests in detail.
| /// Configuration for merging two auth users. | ||
| // TODO (craiglabenz): Should this be a nested field here or should it only be | ||
| // a parameter to initializeAuthServices? | ||
| final UserMergeConfig? userMergeConfig; |
There was a problem hiding this comment.
Discussion: I think we can go with a detached parameter in the AuthServices (and the initializeAuthServices). Since it also specifies merge of UserProfiles and other data, it has a broader scope that just AuthUsers. This will also contribute to a cleaner addition, as you wrote in other places.
| const UserMergeConfig({ | ||
| required this.mergeHandler, | ||
| this.mergeHooks = const [], | ||
| this.shouldDeleteUser = false, | ||
| this.shouldMergeCoreData = true, | ||
| }); |
There was a problem hiding this comment.
Discussion: A few considerations about this class:
- Instead of being a required parameter, could we make the
mergeHandlerthrow by default? This would allow the config to be always present (just as the others in theAuthServicesclass) while preventing unplanned merges that could result in loss of data. - Should this class expose a method to allow adding merge hooks officially? It would then be usable by our IDPs to add their specific merges instead of having a single
defaultIdpMergeHookwith all the providers. The hook could be done inside the initialization of the provider. - Should
mergeHooksbeUserMergeHandleror a different type that returnsFuture<void>? At a first glance as a user, I would expect to have one callback that merges theAuthUser/UserProfileand other merge hooks that are only going to propagate the merge on specific domains. Returning the auth user on each hook opens the possibility of one hook returning the new and the other the old, which would produce a lot of inconsistencies. - I'd suggest having
shouldDeleteUseras true by default. Because the expected result of a merge of 2 accounts is having only 1 left, and not having to manually cleanup afterwards. The option would be more for custom merge implementations that require preserving the old account. - Instead of a boolean
shouldMergeCoreData, why not have it as a separate merge handler in the config? This makes the logic extensible/replaceable and very easy for users to inspect - since the function will be there in the signature and not be some behavior deep inside the implementation changing with a top-level flag.
There was a problem hiding this comment.
-
RE the default method that throws: I think that's reasonable. The trade-offs I was weighing were around the complexity to reach a minimum viable configuration. And, while a required callback parameter seems to add to complexity rather than diminish it, I think it is more self-explanatory than an optional parameter which could be ignored and throws by default. This scenario might only reveal itself later on (especially for new Serverpod developers who haven't yet internalized all of the auth systems).
I don't think either option is clearly superior, so if it's more idiomatic to Serverpod's existing patterns to go with a default function which throws, I am happy to make that change.
-
An official callback registration function would be a nice improvement. I'll convert that.
-
I was probably being un-idiomatic in writing methods that each returned a user. My goal was to have each method accept the latest user model and not have to continuously run queries to reload the same user again and again and again, but in reflecting, I think the reloading pattern is actually closer to how Serverpod handles this kind of thing elsewhere; favoring cleanliness and correctness over sweating every last query.
I can convert this.
-
Fair. I'm just always so nervous about deleting data. What if a developer forgets some portion of their application merge? I'd feel bad having written the code to delete the unwanted
AuthUserfor them, thus taking down that extra data with it. Still, you're right. -
I simultaneously like your suggestion to convert the core data merge parameter from a boolean into a default callback and an wondering when a developer would override this. Surely any such situation is actually just a bug in Serverpod?
There was a problem hiding this comment.
An official callback registration function would be a nice improvement. I'll convert that.
I'm having second thoughts about the callback registration function, as it would require the other Idps to become aware of the active account merging utility (or its config object). This likely requires a two-phase initialization process, as the account merging utility must first be registered before the other Idps could find it. Conversely, in the current setup, the developer would simply register static functions from all of their Idps in their initial setup.
On a separate thought, it would be nice if the auth system could opaquely allow each Idp to register its merge function so that developers don't have to; but that would likely require a base class which informs the existence of such a merge method (currently named migrate in this PR). Without this additional layer to power said automatic registration, I am no longer seeing much benefit in the registration function.
I will implement the rest of this refactor but hold on this part for now until we discuss further.
There was a problem hiding this comment.
RE the default method that throws: I think that's reasonable. The trade-offs I was weighing were around the complexity to reach a minimum viable configuration. And, while a required callback parameter seems to add to complexity rather than diminish it, I think it is more self-explanatory than an optional parameter which could be ignored and throws by default. This scenario might only reveal itself later on (especially for new Serverpod developers who haven't yet internalized all of the auth systems).
I don't think either option is clearly superior, so if it's more idiomatic to Serverpod's existing patterns to go with a default function which throws, I am happy to make that change.
It would be indeed more idiomatic, since all configs are constant and always present. In the end, we would just be moving the place to throw if a merge is attempted when no configuration was made.
- I simultaneously like your suggestion to convert the core data merge parameter from a boolean into a default callback and an wondering when a developer would override this. Surely any such situation is actually just a bug in Serverpod?
A few use cases I can think of are if users want to do custom merging when one of the users possesses certain scopes (like admin), filter which tokens are kept valid, hook custom profile relations merging, wrap the operation in audit logging, etc.
I'm having second thoughts about the callback registration function, as it would require the other Idps to become aware of the active account merging utility (or its
configobject). This likely requires a two-phase initialization process, as the account merging utility must first be registered before the other Idps could find it. Conversely, in the current setup, the developer would simply register static functions from all of their Idps in their initial setup.On a separate thought, it would be nice if the auth system could opaquely allow each Idp to register its merge function so that developers don't have to; but that would likely require a base class which informs the existence of such a merge method (currently named
migratein this PR). Without this additional layer to power said automatic registration, I am no longer seeing much benefit in the registration function.I will implement the rest of this refactor but hold on this part for now until we discuss further.
Maybe a middle-groud here is to merge the IDPs from other mechanism that is evaluated when the request comes instead of when the config is created. For example, by exposing a mergeIdentityProviders method on the AuthServices class that iterates over existing providers calling their respective merge callback. Then, the mergeHooks would be only for custom hooks from users, and not for internal usage on the package.
Such approach would achieve both the seamless "just works" with no coupling on the IDP level. It would also prevent unexpected behavior in case users forget to add the callback. What do you think?
| /// Merges core Serverpod data from [userToRemove] to [userToKeep]. | ||
| /// | ||
| /// This includes: | ||
| /// - Merging scopes | ||
| /// - Moving access tokens and refresh tokens | ||
| /// - Moving user profile (if userToKeep does not have one) | ||
| Future<AuthUserModel> mergeCoreServerpodData( | ||
| final Session session, | ||
| AuthUserModel userToKeep, | ||
| final AuthUserModel userToRemove, { | ||
| required final Transaction transaction, | ||
| }) async { | ||
| // Merge Scopes | ||
| final combinedScopes = { | ||
| ...userToKeep.scopeNames, | ||
| ...userToRemove.scopeNames, | ||
| }; | ||
| if (combinedScopes.length > userToKeep.scopeNames.length) { | ||
| userToKeep = userToKeep.copyWith(scopeNames: combinedScopes); | ||
| userToKeep = (await AuthUser.db.updateRow( | ||
| session, | ||
| userToKeep.toEntity(), | ||
| transaction: transaction, | ||
| )).toModel(); | ||
| } | ||
|
|
||
| // Move Refresh Tokens | ||
| await RefreshToken.db.updateWhere( | ||
| session, | ||
| where: (final t) => t.authUserId.equals(userToRemove.id), | ||
| columnValues: (final t) => [t.authUserId(userToKeep.id)], | ||
| transaction: transaction, | ||
| ); | ||
|
|
||
| // Load existing profiles | ||
| final profiles = await Future.wait([ | ||
| UserProfile.db.findFirstRow( | ||
| session, | ||
| where: (final t) => t.authUserId.equals(userToKeep.id), | ||
| transaction: transaction, | ||
| ), | ||
|
|
||
| UserProfile.db.findFirstRow( | ||
| session, | ||
| where: (final t) => t.authUserId.equals(userToRemove.id), | ||
| transaction: transaction, | ||
| ), | ||
| ]); | ||
| final keepProfile = profiles[0]; | ||
| final removeProfile = profiles[1]; | ||
|
|
||
| // Merge User Profile | ||
| if (removeProfile != null) { | ||
| if (keepProfile != null) { | ||
| // Both profiles exist, merge fields | ||
| final mergedProfile = keepProfile.merge(removeProfile); | ||
| await UserProfile.db.updateRow( | ||
| session, | ||
| mergedProfile, | ||
| transaction: transaction, | ||
| ); | ||
| // Don't need to remove the userToRemove profile since that database | ||
| // relationship is set to onDelete=cascade. | ||
| } else { | ||
| session.log( | ||
| 'No profile found for user to keep ${userToKeep.id}, moving profile ' | ||
| 'from to-be-deleted user ${userToRemove.id}. This likely constitutes ' | ||
| 'an error -- please file an issue with the Serverpod team.', | ||
| level: LogLevel.warning, | ||
| ); | ||
| // Target has no profile, move the origin profile over | ||
| await UserProfile.db.updateRow( | ||
| session, | ||
| removeProfile.copyWith(authUserId: userToKeep.id), | ||
| columns: (final t) => [t.authUserId], | ||
| transaction: transaction, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return userToKeep; | ||
| } |
There was a problem hiding this comment.
Discussion: As suggested in the config comment, instead of having this as a single function, we could have a separate object on the config class to register each individual handler for the core data. It would allow users to do granular changes - if they want a custom profile merging, but without opting-out from all the rest or having to copy it - and also a easy comprehension on everything that is merged by only looking at the config class.
There was a problem hiding this comment.
I have applied the other refactoring suggestions, but as stated above am pausing on this suggestion until further discussion.
There was a problem hiding this comment.
As mentioned on the use cases above, having the possibility of overriding core data merge handlers individually would make very easy to deal with custom use cases. The most common example might be if I have a custom profile class that is a relation towards the AuthUser that I want to merge before the old one is deleted.
e6c25aa to
f176e89
Compare
|
I forgot to pull the commit you proposed within GitHub, which created a tangled mess in the branch; so I reset everything. My two separate commits and your suggestion are now just 1 blob. |
Adds
AuthUsermerging logic to be called in certain scenarios while linking new Idps to users. When to call this code in that linking process is outside the scope of this PR.The main entry point of this PR's changes is in [AuthUsers.merge], which makes use of a new [UserMergeConfig] object to handle the complicated tasks of merging two accounts.
The PR provides two merging functions to get users started:
defaultIdpMergeHookwhich merges all Idp<Provider>Accountrecords andmergeCoreServerpodDatato, well, merge core Serverpod data.Developers are able to use these methods and provide their own additional method for application-specific logic. They can also opt out of Serverpod's default account merging offerings and handle everything themselves (though that would likely imply some necessary changes in the default offerings).
defaultIdpMergeHookmethod, this PR also addsmigratemethods to each Idp class which knows how to move a record from one user to another.Contributes to #4379
Pre-launch Checklist
///), and made sure that the documentation follows the same style as other Serverpod documentation. I checked spelling and grammar.If you need help, consider asking for advice on the discussion board.
Breaking changes
No breaking changes.