Skip to content

feat: Add account merging mechanics to auth module#4752

Open
craiglabenz wants to merge 1 commit into
serverpod:mainfrom
craiglabenz:account-merging
Open

feat: Add account merging mechanics to auth module#4752
craiglabenz wants to merge 1 commit into
serverpod:mainfrom
craiglabenz:account-merging

Conversation

@craiglabenz
Copy link
Copy Markdown
Contributor

@craiglabenz craiglabenz commented Feb 19, 2026

Adds AuthUser merging 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: defaultIdpMergeHook which merges all Idp <Provider>Account records and mergeCoreServerpodData to, 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).

  • [Note]: To power the defaultIdpMergeHook method, this PR also adds migrate methods to each Idp class which knows how to move a record from one user to another.

Contributes to #4379

Pre-launch Checklist

  • I read the Contribute page and followed the process outlined there for submitting PRs.
  • This update contains only one single feature or bug fix and nothing else. (If you are submitting multiple fixes, please make multiple PRs.)
  • I read and followed the Dart Style Guide and formatted the code with dart format.
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///), and made sure that the documentation follows the same style as other Serverpod documentation. I checked spelling and grammar.
  • I added new tests to check the change I am making.
  • All existing and new tests are passing.
  • Any breaking changes are documented below.

If you need help, consider asking for advice on the discussion board.

Breaking changes

No breaking changes.

@craiglabenz craiglabenz changed the title [feat] Account merging feat: Add account merging mechanics to auth module Feb 20, 2026
Copy link
Copy Markdown
Collaborator

@marcelomendoncasoares marcelomendoncasoares left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +25 to +28
/// 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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +39 to +44
const UserMergeConfig({
required this.mergeHandler,
this.mergeHooks = const [],
this.shouldDeleteUser = false,
this.shouldMergeCoreData = true,
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Discussion: A few considerations about this class:

  1. Instead of being a required parameter, could we make the mergeHandler throw by default? This would allow the config to be always present (just as the others in the AuthServices class) while preventing unplanned merges that could result in loss of data.
  2. 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 defaultIdpMergeHook with all the providers. The hook could be done inside the initialization of the provider.
  3. Should mergeHooks be UserMergeHandler or a different type that returns Future<void>? At a first glance as a user, I would expect to have one callback that merges the AuthUser/UserProfile and 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.
  4. I'd suggest having shouldDeleteUser as 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.
  5. 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.

Copy link
Copy Markdown
Contributor Author

@craiglabenz craiglabenz Feb 28, 2026

Choose a reason for hiding this comment

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

  1. 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.

  2. An official callback registration function would be a nice improvement. I'll convert that.

  3. 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.

  4. 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 AuthUser for them, thus taking down that extra data with it. Still, you're right.

  5. 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?

Copy link
Copy Markdown
Contributor Author

@craiglabenz craiglabenz Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator

@marcelomendoncasoares marcelomendoncasoares Mar 6, 2026

Choose a reason for hiding this comment

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

  1. 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.

  1. 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 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.

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?

Comment on lines +264 to +345
/// 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;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

@craiglabenz craiglabenz Mar 1, 2026

Choose a reason for hiding this comment

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

I have applied the other refactoring suggestions, but as stated above am pausing on this suggestion until further discussion.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@craiglabenz
Copy link
Copy Markdown
Contributor Author

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.

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.

2 participants