ABP Version: 10.4.1 (Identity Pro)
Module: Volo.Abp.Identity (IdentityUserAppService)
Reproduction steps:
- Create a user assigned to one or more Organization Units.
- Call
IIdentityUserAppService.UpdateAsync(id, input)whereinput.RoleNamesis set (e.g. changing the user's role) butinput.OrganizationUnitIdsis leftnull(i.e. the caller isn't touching OU membership at all — a very natural shape for a UI that has a separate "roles" tab). - Caller has both
AbpIdentity.Users.Update.ManageRolesandAbpIdentity.Users.Update.ManageOU.
Expected: the user's OU membership is left untouched, since OrganizationUnitIds was never provided.
Actual: the user's OU membership is deleted entirely (empty).
Root cause:
UpdateUserByInput (Volo/Abp/Identity/IdentityUserAppService.cs):
if (await PermissionChecker.IsGrantedAsync(IdentityPermissions.Users.ManageRoles) && input.RoleNames != null)
{
await UpdateUserRolesBasedOnOrganizationUnits(user, input);
var effectiveRoles = await FilterRolesByCurrentUserAsync(user, input.RoleNames);
(await UserManager.SetRolesAsync(user, effectiveRoles)).CheckErrors();
}
if (await PermissionChecker.IsGrantedAsync(IdentityPermissions.Users.ManageOU) && input.OrganizationUnitIds != null)
{
await UserManager.SetOrganizationUnitsAsync(user, input.OrganizationUnitIds);
}
UpdateUserRolesBasedOnOrganizationUnits (called from the first block, above) does:
protected virtual async Task UpdateUserRolesBasedOnOrganizationUnits(IdentityUser user, IdentityUserCreateOrUpdateDtoBase input)
{
input.OrganizationUnitIds ??= Array.Empty<Guid>();
...
}
This mutates the shared input.OrganizationUnitIds from null to [] as a side effect of updating roles. The second if block above then sees input.OrganizationUnitIds != null (now true, since it's [] not null) and calls SetOrganizationUnitsAsync(user, []), clearing all OU membership — even though the caller never intended to touch OUs at all.
Suggested fix: UpdateUserRolesBasedOnOrganizationUnits should operate on a local/temporary variable instead of mutating input.OrganizationUnitIds in place (e.g. var organizationUnitIds = input.OrganizationUnitIds ?? Array.Empty<Guid>();), so the caller's original "did not specify OUs" intent (null) is preserved for the later ManageOU check.
How we found it: integration test creating a user in an OU, then calling UpdateAsync with only RoleNames set — a follow-up GetAsync showed the OU membership gone.
2 Answer(s)
-
0
This is a known bug in the
IdentityUserAppServiceupdate flow (as you diagnosed):UpdateUserRolesBasedOnOrganizationUnits(...)mutatesinput.OrganizationUnitIds(??=), which unintentionally turns “not provided” (null) into “provided but empty” ([]). Since the caller also has ManageOU, the later block runs and callsSetOrganizationUnitsAsync(user, []), clearing all OU memberships.Workaround (no framework modification): override
UpdateAsyncand preserve the original intent (null)- Create a class that derives from
IdentityUserAppService. - Capture
input.OrganizationUnitIdsinto a local variable before calling base. - After base call, if the caller did not intend to update OUs (original was
null), restore OU memberships (re-apply existing memberships), or run a custom “roles-only” update path.
Example approach (restore memberships when the input didn’t specify OUs):
- Create a class that derives from
using Volo.Abp.Identity; using Volo.Abp.Application.Dtos; public class MyIdentityUserAppService : IdentityUserAppService { public MyIdentityUserAppService( IdentityUserManager userManager, IIdentityUserRepository userRepository, IIdentityRoleRepository roleRepository, IIdentityClaimTypeRepository claimTypeRepository, IOrganizationUnitRepository organizationUnitRepository) : base(userManager, userRepository, roleRepository, claimTypeRepository, organizationUnitRepository) { } public override async Task<IdentityUserDto> UpdateAsync(Guid id, IdentityUserUpdateDto input) { // Preserve original caller intent var originalOuIds = input.OrganizationUnitIds; // If caller didn't send OUs, fetch current memberships so we can restore them Guid[]? currentOuIds = null; if (originalOuIds == null) { var user = await UserManager.GetByIdAsync(id); var ous = await UserManager.GetOrganizationUnitsAsync(user); currentOuIds = ous.Select(x => x.Id).ToArray(); } var result = await base.UpdateAsync(id, input); // Restore memberships only when caller didn't touch OUs if (originalOuIds == null && currentOuIds != null) { var user = await UserManager.GetByIdAsync(id); await UserManager.SetOrganizationUnitsAsync(user, currentOuIds); } return result; } }Register your derived service as a replacement (ABP standard DI replacement pattern).
Proper fix (framework side): exactly what you suggested—
UpdateUserRolesBasedOnOrganizationUnitsshould use a local variable instead of mutatinginput.OrganizationUnitIds. Until that’s released in the framework, the override workaround above prevents OU loss.If you also control the UI/API contract, a simpler mitigation is: always send
OrganizationUnitIdsexplicitly when the current user has ManageOU (e.g., send the existing OU ids back), but your “roles tab” scenario is valid, so the service-side workaround is usually preferable.
(Verified docs related to Organization Units, for context only:)
- https://abp.io/docs/10.6/modules/identity
- https://abp.io/docs/latest/modules/identity-pro
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
That's a bug on our side.
UpdateUserRolesBasedOnOrganizationUnitsshouldn't write back toinput, and we're changing it to a local variable exactly the way you suggested.Until that ships, you can override the app service and put the caller's original value back after the base call:
using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Caching; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus.Distributed; using Volo.Abp.Identity; using Volo.Abp.Identity.Emailing; using Volo.Abp.Identity.UserInvitations; using Volo.Abp.Identity.UserSharing; using Volo.Abp.Threading; namespace MyCompanyName.MyProjectName.Identity; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(IIdentityUserAppService))] public class MyIdentityUserAppService : IdentityUserAppService { public MyIdentityUserAppService( IdentityUserManager userManager, IIdentityUserRepository userRepository, IIdentityRoleRepository roleRepository, IOrganizationUnitRepository organizationUnitRepository, IIdentityClaimTypeRepository identityClaimTypeRepository, IdentityProTwoFactorManager identityProTwoFactorManager, IOptions<IdentityOptions> identityOptions, IDistributedEventBus distributedEventBus, IOptions<AbpIdentityOptions> abpIdentityOptions, IPermissionChecker permissionChecker, IDistributedCache<IdentityUserDownloadTokenCacheItem, string> downloadTokenCache, IDistributedCache<ImportInvalidUsersCacheItem, string> importInvalidUsersCache, IdentitySessionManager identitySessionManager, IdentityUserTwoFactorChecker identityUserTwoFactorChecker, ICancellationTokenProvider cancellationTokenProvider, UserSharingManager userSharingManager, UserInvitationManager userInvitationManager, IIdentityUserInvitationRepository userInvitationRepository, IIdentityEmailSender identityEmailSender) : base( userManager, userRepository, roleRepository, organizationUnitRepository, identityClaimTypeRepository, identityProTwoFactorManager, identityOptions, distributedEventBus, abpIdentityOptions, permissionChecker, downloadTokenCache, importInvalidUsersCache, identitySessionManager, identityUserTwoFactorChecker, cancellationTokenProvider, userSharingManager, userInvitationManager, userInvitationRepository, identityEmailSender) { } protected override async Task UpdateUserRolesBasedOnOrganizationUnits(IdentityUser user, IdentityUserCreateOrUpdateDtoBase input) { var organizationUnitIds = input.OrganizationUnitIds; await base.UpdateUserRolesBasedOnOrganizationUnits(user, input); input.OrganizationUnitIds = organizationUnitIds; } }Drop it into your
*.Applicationproject. That constructor is the 10.4 one and it does change between versions, so re-check it if you upgrade before the fix is out.If you'd rather not replace the service, sending the current ids from the caller works too:
input.OrganizationUnitIds = (await _identityUserAppService.GetOrganizationUnitsAsync(id)) .Select(x => x.Id) .ToArray(); await _identityUserAppService.UpdateAsync(id, input);The fix will be in v10.7.0. Your ticket has been refunded.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)