Hello!
We're having a couple of issues regarding user management that we would like some help with:
Issue 1: Shared Accounts Leave Tenant
We recently migrated from ABP 10.1 to ABP 10.3 and enabled Shared Accounts. In our setup we use separate databases for each tenant so as part of the migration we created a record in the host's AbpUsers table for each user in every tenant AbpUsers table. After the migration, login and switch tenant works without issues but we're having an issue with the "Leave Tenant" option on pre-migration tenants. Whenever a user clicks "leave tenant" the tenant's AbpUsers table correctly updates Leaved = 1 but it doesn't update the host's AbpUsers table so when the user logs in they still see the tenant they left. This is not a problem if we create a new tenant, just the tenants we had before the migration are having this issue.
Issue 2: Deleting a User Account
We wanted to delete an existing user from the system but we couldn't find a "delete" action in Administration > Identity management > Users, see screenshot below:
. Are we missing something?
Thank you!
20 Answer(s)
-
0
Issue 1 (Shared Accounts – “Leave Tenant” doesn’t update Host user / tenant list still shown):
With Shared User Accounts (TenantUserSharingStrategy.Shared), the “leave tenant” action doesn’t primarily rely on flipping theLeavedflag on a tenantAbpUsersrow. Instead, ABP maintains a host-side membership/association for the user↔tenant relationship, and “Leave” marks that membership as left so the tenant no longer appears in the tenant switcher list.In your migration, you manually created host
AbpUsersrows for tenant users (which is required for database-per-tenant scenarios), but for pre-migration tenants it sounds like the user↔tenant association data wasn’t created/matched correctly. As a result, the “Leave Tenant” operation updates something in the tenant DB (you seeLeaved = 1), but the host-side association that drives the visible tenant list remains effectively “joined”.What to do (verified approach):
- Ensure you migrated not only users, but also the shared-accounts tenant membership/association records in the Host database (the thing that backs “Switch Tenant” / “Leave”).
If these association rows are missing or don’t match the sameUserIdacross host and tenant databases, host will still think the user is a member, and the tenant will remain visible after login. - Re-run/implement a migration step that, for every existing tenant user, creates the proper host-side membership record linking that single global (host) user id to the tenant.
(ABP’s docs explicitly call out that for separate databases you must ensure host DB contains the matching user records, then “the framework can create/manage the user-to-tenant associations”. If you created users manually, you also need to ensure the associations are consistent.) - After fixing associations, “Leave Tenant” should correctly remove the tenant from the switch list because the membership is marked left/inactive.
- Ensure you migrated not only users, but also the shared-accounts tenant membership/association records in the Host database (the thing that backs “Switch Tenant” / “Leave”).
Issue 2 (No “Delete” action for users in Identity Management UI):
In ABP, deleting identity users from the UI is typically governed by permissions. If the current admin account doesn’t have the delete permission, the UI won’t show the delete action.What to do:
- Go to Administration → Identity Management → Roles.
- Edit the role of your current admin (often
admin) and open Permissions. - Grant the user deletion permission under the Identity permissions (the “delete user” permission).
- Refresh the page; the “Delete” action should appear in the user actions menu.
Sources (validated):
- https://abp.io/docs/10.3/modules/account/shared-user-accounts
- https://abp.io/docs/latest/modules/account/shared-user-accounts
- https://abp.io/docs/latest/modules/identity
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
Issue 1 - Can you elaborate on what shared-accounts tenant membership/association records are? I can see that the host's
AbpUserstable has one row per user and tenant and has a valid id in the tenant id column. I assumed that there's some process that when the user leaves a tenant it queries the host'sAbpUserstable with the user's email and tenant id and updatesLeaved = 1in that table. Also worth mentioning that the switch option works, it's the Leave option that doesn't.Issue 2: I am using the default admin account which has the admin role assigned and that role has the user management delete permission, attaching an image of the admin's permissions screen:
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Both issues come from the Shared User Accounts strategy.
Issue 1 – "Leave Tenant" doesn't update the Host record
For a tenant with a separate database, the host-side "shadow" row must have a different
Idthan the tenant user. That's how the framework detects a separate database: if the shadowIddiffers from the tenantIdit updates the host row on Leave; if they're equal it assumes a shared database (same row, already updated) and skips it.During the migration you most likely reused the same
Idas the tenant user, so Leave skips the host update. New tenants work because the framework generates a freshIdfor the shadow. (The sameIdcollision also breaks social-login / passkey sync to the host, so it's worth fixing.)To fix your data:
- For users who already left, set
Leaved = 1on the matching host row (by email +TenantId). - Regenerate a new unique
Idfor the per-tenant host shadow rows so it no longer equals the tenant userId:
-- Host database: the per-tenant shadow rows created during migration SELECT Id, UserName, Email, TenantId, Leaved FROM AbpUsers WHERE TenantId IS NOT NULL;Before re-keying, check these rows aren't referenced by
AbpUserRoles/AbpUserLogins/AbpUserTokensin the host DB. Since the exact script depends on how your migration mapped users, feel free to send the table layout to liming.ma@volosoft.com and we'll help build it. We'll also improve this on our side so Leave no longer relies purely on theIdcomparison.Issue 2 – No "Delete" action in the Users list
This is by design with Shared accounts, not a permission issue. A user is a global account, so:
- Delete the account is a host-level operation.
- Remove a user from one tenant is done with Leave Tenant (soft removal, re-invitable).
That's why Delete is hidden in the tenant UI. One catch: the UI currently hides Delete whenever Shared accounts is on without checking host vs tenant, so even a host admin doesn't see it even though the host is allowed to delete – that part is a UI bug we'll fix.
Are you trying to delete as a host admin (remove the account) or inside a tenant (remove a user from that tenant)? For the tenant case, Leave Tenant is the intended action; for the host case, deletion is supported and the missing button is the bug we'll address.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - For users who already left, set
-
0
Issue 1: Yes, I reused the same id from the tenant table. I will change that in the host table and re-test it. Issue 2: Just to clarify, a host admin user could delete a user inside a tenant? In our system a tenant is a company and an employee could leave the company, so if the employee leaves the company can the host admin delete that user?
Thanks for the help!
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Glad you've pinpointed the Id reuse. Once those host shadow rows get fresh Ids (different from the tenant user
Id), the leave flow will sync host and tenant correctly.For Issue 2, technically a host admin can call Delete on the host Users page, but with Shared user accounts "delete" removes the global account. If that user is a member of other tenants those memberships disappear too, and the account is gone — usually not what you want when an employee leaves one company.
The intended action is the per-tenant Leave (soft,
Leaved=true), which keeps the global account intact and lets you re-invite the user later. Today it is wired as a self-service action underSwitch tenant → Leave. For the admin-driven case in v10.3 you can add a small AppService plus a UI action on the host Users page.- Add the AppService:
using System; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Volo.Abp.Application.Services; using Volo.Abp.Data; using Volo.Abp.Domain.Entities; using Volo.Abp.Identity; using Volo.Abp.Identity.UserSharing; using Volo.Abp.MultiTenancy; namespace MyCompanyName.MyProjectName.Identity; [Authorize(IdentityPermissions.Users.Delete)] public class TenantUserManagementAppService : ApplicationService { protected IdentityProUserManager UserManager { get; } protected UserSharingManager UserSharingManager { get; } protected IDataFilter DataFilter { get; } public TenantUserManagementAppService( IdentityProUserManager userManager, UserSharingManager userSharingManager, IDataFilter dataFilter) { UserManager = userManager; UserSharingManager = userSharingManager; DataFilter = dataFilter; } public virtual async Task RemoveUserFromTenantAsync(Guid tenantId, Guid userId) { string email; using (CurrentTenant.Change(tenantId)) using (DataFilter.Disable<IIdentityUserLeavedFilter>()) { var tenantUser = await UserManager.FindByIdAsync(userId.ToString()); if (tenantUser == null) { throw new EntityNotFoundException(typeof(IdentityUser), userId); } email = tenantUser.Email; if (!tenantUser.Leaved) { tenantUser.SetLeaved(true); (await UserManager.UpdateAsync(tenantUser)).CheckErrors(); } } using (CurrentTenant.Change(null)) using (DataFilter.Disable<IMultiTenant>()) using (DataFilter.Disable<IIdentityUserLeavedFilter>()) { var hostShadows = await UserSharingManager.GetUsersByEmailFromHostAsync(email, includeLeaved: true); foreach (var shadow in hostShadows.Where(x => x.TenantId == tenantId && !x.Leaved)) { shadow.SetLeaved(true); (await UserManager.UpdateWithoutValidationAsync(shadow)).CheckErrors(); } } } }- Add an action on the host Users page. This inherits the Identity Pro
UserManagementcomponent and appends a "Remove from tenant" action that shows up only on rows whereTenantId != null(i.e. host shadows for some tenant):
using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using MyCompanyName.MyProjectName.Identity; using Volo.Abp.AspNetCore.Components.Web.Extensibility.EntityActions; using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; using Volo.Abp.Identity.Pro.Blazor.Pages.Identity; using Volo.Abp.MultiTenancy; namespace MyCompanyName.MyProjectName.Blazor.Pages.Identity; [ExposeServices(typeof(UserManagement))] [Dependency(ReplaceServices = true)] public class CustomizedUserManagement : UserManagement { [Inject] protected TenantUserManagementAppService TenantUserManagementAppService { get; set; } = default!; protected override async ValueTask SetEntityActionsAsync() { await base.SetEntityActionsAsync(); var removeFromTenantAction = new EntityAction { Text = "Remove from tenant", Visible = (data) => { var user = (IdentityUserDto)data; return MultiTenancyOptions.Value.UserSharingStrategy == TenantUserSharingStrategy.Shared && CurrentTenant.Id == null && user.TenantId.HasValue && user.Id != CurrentUser.Id && HasDeletePermission; }, Clicked = async (data) => { var user = (IdentityUserDto)data; await TenantUserManagementAppService.RemoveUserFromTenantAsync(user.TenantId!.Value, user.Id); await Notify.Success("The user has been removed from the tenant."); await GetEntitiesAsync(); await InvokeAsync(StateHasChanged); }, ConfirmationMessage = (data) => $"Are you sure you want to remove the user '{((IdentityUserDto)data).UserName}' from this tenant?" }; EntityActions.Get<UserManagement>().Add(removeFromTenantAction); } }A host admin sees the new action on each tenant-shadow row in the host Users page, removes the user from the chosen tenant, the global account stays, and the user can be re-invited later. The two-step flow also sidesteps the Id-equality check inside the framework's
LeaveTenantAsync, so it works even on host shadow rows that still share the tenant user'sId.The next release will ship a built-in Remove from tenant action for tenant admins on the tenant-side user list (with
Identity.Users.Delete). Once you upgrade, the custom AppService and the host-side action above can be removed; if you still want a host-side "remove a user from a specific tenant" admin tool for ops/batch scenarios, you can keep this workaround as your own management feature.Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Sorry, we seem to have more issues around shared accounts:
- Settings option inside a tenant is not working anymore: I logged in as an admin to a tenant, clicked the settings option and nothing is displayed.

I can see this in the logs but the user is an admin so not sure what could be going on: [16:22:44 INF] Authorization failed. These requirements were not met: PermissionRequirement: AuditLogging.AuditLogs.SettingManagement [16:22:44 INF] Authorization failed. These requirements were not met: PermissionRequirement: FeatureManagement.ManageHostFeatures [16:22:44 INF] Authorization failed. These requirements were not met: PermissionRequirement: Saas.SettingManagement
Force password changedoesn't work at a tenant level: If I login to a tenant, edit a user and click "Force change password" it is not forcing the user to change the password. The logs show that the configuration is correct and there's a redirect to change password but the screen where it asks the user to change password is never displayed it only reloads the login:
[16:28:56 INF] Executing handler method Volo.Abp.Account.Public.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is Valid [16:28:57 INF] Try to use LDAP for external authentication [16:28:57 WRN] Ldap login feature is not enabled! [16:28:57 INF] Try to use OAUTH for external authentication [16:28:57 DBG] OAuth login feature is not enabled! [16:28:57 WRN] The user should change password! (username: "test.password", id:"cc9a955b-932b-adb2-4eee-3a219c847c79") [16:28:57 INF] AuthenticationScheme: Abp.Account.Cookie signed in. [16:28:57 INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.RedirectToPageResult. [16:28:57 INF] Executing RedirectToPageResult, redirecting to ./ChangePassword. [16:28:57 INF] Executed page /Account/Login in 427.6961ms [16:28:57 INF] Executed endpoint '/Account/Login' [16:28:57 INF] Request finished HTTP/2 POST https://localhost:44335/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3DPortal_BlazorServer%26redirect_uri%3Dhttps%253A%252F%252Flocalhost%253A44337%252Fsignin-oidc%26response_type%3Dcode%2520id_token%26scope%3Dopenid%2520profile%2520roles%2520email%2520phone%2520Portal%26response_mode%3Dform_post%26nonce%3D639160361142643200.OTgwZmEyNzgtMjk4OC00NDg3LTkxMDAtMmI0OWI2ZjMwY2I5N2U4NGE2ZTAtMGNmMC00NTM0LWE0ZDItZTM3ODg3ZjYxNDZl%26state%3DCfDJ8HsL4swFLDZJp_SIAjZzOqL15zWxL_CN9BfpTOm1xPnMPvdbmvBH9pNNrntcNC48W1UWcvwO_-H76ZF26a6iJiJ-4-UMlEEIp_235AALPZSWoeR1urtIy0FMRRT3PCsbMsOEyflmYy8fK67gaQ9NCjbxyFxdAVguGU0hJMWSrijjNpN6n_3wXFO-Xfzt0zDN7HhZjJORBKPQktPSyQQZEoFNzyfAg6qAaLT1nmzYNJ10bK0f5KeWdPo9ea1vcOIRGvtNkc_08Au7vxmlTjtw-CUElYr4PXasusqjBotXz-Qb%26x-client-SKU%3DID_NET10_0%26x-client-ver%3D8.16.0.0 - 302 0 null 442.6244ms [16:28:57 INF] Request starting HTTP/2 GET https://localhost:44335/Account/ChangePassword?returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3DPortal_BlazorServer%26redirect_uri%3Dhttps%253A%252F%252Flocalhost%253A44337%252Fsignin-oidc%26response_type%3Dcode%2520id_token%26scope%3Dopenid%2520profile%2520roles%2520email%2520phone%2520Portal%26response_mode%3Dform_post%26nonce%3D639160361142643200.OTgwZmEyNzgtMjk4OC00NDg3LTkxMDAtMmI0OWI2ZjMwY2I5N2U4NGE2ZTAtMGNmMC00NTM0LWE0ZDItZTM3ODg3ZjYxNDZl%26state%3DCfDJ8HsL4swFLDZJp_SIAjZzOqL15zWxL_CN9BfpTOm1xPnMPvdbmvBH9pNNrntcNC48W1UWcvwO_-H76ZF26a6iJiJ-4-UMlEEIp_235AALPZSWoeR1urtIy0FMRRT3PCsbMsOEyflmYy8fK67gaQ9NCjbxyFxdAVguGU0hJMWSrijjNpN6n_3wXFO-Xfzt0zDN7HhZjJORBKPQktPSyQQZEoFNzyfAg6qAaLT1nmzYNJ10bK0f5KeWdPo9ea1vcOIRGvtNkc_08Au7vxmlTjtw-CUElYr4PXasusqjBotXz-Qb%26x-client-SKU%3DID_NET10_0%26x-client-ver%3D8.16.0.0&RememberMe=False - null null [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessRequestContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ResolveRequestUri. [16:28:57 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ResolveRequestUri. [16:28:57 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.OpenIddictServerHandlers+InferEndpointType. [16:28:57 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by Volo.Abp.Account.Web.Pages.Account.OpenIddictImpersonateInferEndpointType. [16:28:57 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ValidateTransportSecurityRequirement. [16:28:57 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ValidateHostHeader. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ValidateHostHeader. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.OpenIddictValidationHandlers+EvaluateValidatedTokens. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromAuthorizationHeader. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromBodyForm. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromQueryString. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractClientCertificate. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.OpenIddictValidationHandlers+ValidateRequiredTokens. [16:28:57 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was marked as rejected by OpenIddict.Validation.OpenIddictValidationHandlers+ValidateRequiredTokens. [16:28:57 DBG] AuthenticationScheme: OpenIddict.Validation.AspNetCore was not authenticated. [16:28:57 DBG] Starting resolving tenant... [16:28:57 DBG] Trying to resolve tenant through 'CurrentUser'... [16:28:57 DBG] Trying to resolve tenant through 'AbpAccount'... [16:28:57 DBG] Tenant resolved by 'AbpAccount' as 'Host'. [16:28:57 DBG] No tenant resolved. [16:28:57 INF] Executing endpoint '/Account/ChangePassword' [16:28:57 INF] Route matched with {page = "/Account/ChangePassword", area = "", action = "", controller = ""}. Executing page /Account/ChangePassword [16:28:57 INF] Skipping the execution of current filter as its not the most effective filter implementing the policy Microsoft.AspNetCore.Mvc.ViewFeatures.IAntiforgeryPolicy [16:28:57 INF] Executing handler method Volo.Abp.Account.Public.Web.Pages.Account.ChangePasswordModel.OnGetAsync - ModelState is Valid [16:28:57 DBG] Starting resolving tenant... [16:28:57 DBG] Trying to resolve tenant through 'CurrentUser'... [16:28:57 DBG] Trying to resolve tenant through 'AbpAccount'... [16:28:57 DBG] Tenant resolved by 'AbpAccount' as 'Host'. [16:28:57 DBG] No tenant resolved. [16:28:57 INF] AuthenticationScheme: Abp.Account.Cookie signed out. [16:28:57 INF] Executed handler method OnGetAsync, returned result Microsoft.AspNetCore.Mvc.RedirectToPageResult. [16:28:57 INF] Executing RedirectToPageResult, redirecting to ./Login. [16:28:57 INF] Executed page /Account/ChangePassword in 27.4135ms
Thank you for help.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - Settings option inside a tenant is not working anymore: I logged in as an admin to a tenant, clicked the settings option and nothing is displayed.
-
0
Hi,
For Problem 1 (the empty Settings page): under Shared user accounts the identity- and account-related settings (password policy, lockout, external logins, two-factor, etc.) are global to the whole system and managed only on the host side so every tenant shares a consistent policy. That's why the Identity and Account setting groups are hidden from tenants by their contributors. The remaining groups in the page are host-only and get filtered out by the permission check for a tenant admin — that leaves the page blank. We've opened a PR to render a small empty state instead: https://github.com/abpframework/abp/pull/25541. It ships in the next release.
For Problem 2 (Force change password loops back to Login): the
Abp.Account.Cookiepurpose cookie is configured withSecurityStampValidator.ValidatePrincipalAsyncasOnValidatePrincipal, but the cookie is written without a SecurityStamp claim. So onceSecurityStampValidatorOptions.ValidationIntervalelapses, the stamp comparison fails, the cookie is rejected, and ChangePassword signs the user out and redirects back to Login. By default the interval is 30 minutes, so a fresh cookie skips the check and the bug stays hidden — your environment seems to trigger it.You can drop the broken handler in your
Webmodule'sConfigureServicesuntil the next release:PostConfigure<CookieAuthenticationOptions>(AccountPageModel.AccountCookieSchemeName, options => { options.Events.OnValidatePrincipal = ctx => Task.CompletedTask; });The purpose cookie is protected by its 10-minute
ExpireTimeSpanand the purpose claim, so dropping the stamp validation doesn't reduce security. We'll fix this in the next ABP version, and 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) -
0
Hi,
For Problem 2: Should I add that code to the AuthServer module? I tried adding it and it's not working:
Method 'PostConfigure' has 1 parameter(s) but is invoked with 2 argument(s)protected void PostConfigure<TOptions>(Action<TOptions> configureOptions) in class Volo.Abp.Modularity.AbpModuleThanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Sorry, my snippet used the wrong overload. Could you try this instead:
context.Services.PostConfigure<CookieAuthenticationOptions>( AccountPageModel.AccountCookieSchemeName, options => { options.Events.OnValidatePrincipal = ctx => Task.CompletedTask; });Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
I added your snippet in the
ConfigureServicesmethod of theAuthmodule and it did not work, still just sent me back to login.I tried applying
Force change passwordto a Host user account and that worked fine. It's just when I do it from a tenant that it doesn't work. Here are the logs of the test after pasting the code you shared:[22:53:12 WRN] The user should change password! (username: "test.password", id:"cc9a955b-932b-adb2-4eee-3a219c847c79") [22:53:12 INF] AuthenticationScheme: Abp.Account.Cookie signed in. [22:53:12 INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.RedirectToPageResult. [22:53:12 INF] Executing RedirectToPageResult, redirecting to ./ChangePassword. [22:53:12 INF] Executed page /Account/Login in 93.0332ms [22:53:12 INF] Executed endpoint '/Account/Login' [22:53:12 INF] Request finished HTTP/2 POST https://localhost:44335/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3DPortal_BlazorServer%26redirect_uri%3Dhttps%253A%252F%252Flocalhost%253A44337%252Fsignin-oidc%26response_type%3Dcode%2520id_token%26scope%3Dopenid%2520profile%2520roles%2520email%2520phone%2520Portal%26response_mode%3Dform_post%26nonce%3D639160591679453180.NWM1MThkOTItODc3Ni00NTc4LWI2YTMtNjU1MzAxZGY2MDgxZmJjMjRmNWMtY2UwMC00OWIxLWJmMGUtNjU5OGNmNTZiNjE0%26state%3DCfDJ8HsL4swFLDZJp_SIAjZzOqKPpwz1Atc64KXujCT_rFX70jH8J7po6j99e5-gKFoTwQMHsRtFu9kQqvgbARRFPDVRF3qhN5Q4ednZ30-XkWwhtYelKxx-6WXBYNOvyH0V-VpKwI5lmRcuj9VVIjOu-9IPrj0eplKcoXF4qNt3OhMZJkb7zhVZFaQxK2sY3GUs0MAZeTiFUNzzVDa12aipCGcuacvOHaKerQp9HQTxpI3WGDjcVG3LkrBybjFyyiCmTyilPUUSeZxCw7Fx3DxymS4Xkq0TMXYlmZHxd1A_hINr%26x-client-SKU%3DID_NET10_0%26x-client-ver%3D8.16.0.0 - 302 0 null 102.0507ms [22:53:12 INF] Request starting HTTP/2 GET https://localhost:44335/Account/ChangePassword?returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3DPortal_BlazorServer%26redirect_uri%3Dhttps%253A%252F%252Flocalhost%253A44337%252Fsignin-oidc%26response_type%3Dcode%2520id_token%26scope%3Dopenid%2520profile%2520roles%2520email%2520phone%2520Portal%26response_mode%3Dform_post%26nonce%3D639160591679453180.NWM1MThkOTItODc3Ni00NTc4LWI2YTMtNjU1MzAxZGY2MDgxZmJjMjRmNWMtY2UwMC00OWIxLWJmMGUtNjU5OGNmNTZiNjE0%26state%3DCfDJ8HsL4swFLDZJp_SIAjZzOqKPpwz1Atc64KXujCT_rFX70jH8J7po6j99e5-gKFoTwQMHsRtFu9kQqvgbARRFPDVRF3qhN5Q4ednZ30-XkWwhtYelKxx-6WXBYNOvyH0V-VpKwI5lmRcuj9VVIjOu-9IPrj0eplKcoXF4qNt3OhMZJkb7zhVZFaQxK2sY3GUs0MAZeTiFUNzzVDa12aipCGcuacvOHaKerQp9HQTxpI3WGDjcVG3LkrBybjFyyiCmTyilPUUSeZxCw7Fx3DxymS4Xkq0TMXYlmZHxd1A_hINr%26x-client-SKU%3DID_NET10_0%26x-client-ver%3D8.16.0.0&RememberMe=False - null null [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessRequestContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ResolveRequestUri. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ResolveRequestUri. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.OpenIddictServerHandlers+InferEndpointType. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by Volo.Abp.Account.Web.Pages.Account.OpenIddictImpersonateInferEndpointType. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ValidateTransportSecurityRequirement. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ValidateHostHeader. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ValidateHostHeader. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.OpenIddictValidationHandlers+EvaluateValidatedTokens. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromAuthorizationHeader. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromBodyForm. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromQueryString. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractClientCertificate. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.OpenIddictValidationHandlers+ValidateRequiredTokens. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was marked as rejected by OpenIddict.Validation.OpenIddictValidationHandlers+ValidateRequiredTokens. [22:53:12 DBG] AuthenticationScheme: OpenIddict.Validation.AspNetCore was not authenticated. [22:53:12 DBG] Starting resolving tenant... [22:53:12 DBG] Trying to resolve tenant through 'CurrentUser'... [22:53:12 DBG] Trying to resolve tenant through 'AbpAccount'... [22:53:12 DBG] Tenant resolved by 'AbpAccount' as 'Host'. [22:53:12 DBG] No tenant resolved. [22:53:12 INF] Executing endpoint '/Account/ChangePassword' [22:53:12 INF] Route matched with {page = "/Account/ChangePassword", area = "", action = "", controller = ""}. Executing page /Account/ChangePassword [22:53:12 INF] Skipping the execution of current filter as its not the most effective filter implementing the policy Microsoft.AspNetCore.Mvc.ViewFeatures.IAntiforgeryPolicy [22:53:12 INF] Executing handler method Volo.Abp.Account.Public.Web.Pages.Account.ChangePasswordModel.OnGetAsync - ModelState is Valid [22:53:12 INF] AuthenticationScheme: Abp.Account.Cookie signed out. [22:53:12 INF] Executed handler method OnGetAsync, returned result Microsoft.AspNetCore.Mvc.RedirectToPageResult. [22:53:12 INF] Executing RedirectToPageResult, redirecting to ./Login. [22:53:12 INF] Executed page /Account/ChangePassword in 12.0422ms [22:53:12 INF] Executed endpoint '/Account/ChangePassword' [22:53:12 INF] Request finished HTTP/2 GET https://localhost:44335/Account/ChangePassword?returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3DPortal_BlazorServer%26redirect_uri%3Dhttps%253A%252F%252Flocalhost%253A44337%252Fsignin-oidc%26response_type%3Dcode%2520id_token%26scope%3Dopenid%2520profile%2520roles%2520email%2520phone%2520Portal%26response_mode%3Dform_post%26nonce%3D639160591679453180.NWM1MThkOTItODc3Ni00NTc4LWI2YTMtNjU1MzAxZGY2MDgxZmJjMjRmNWMtY2UwMC00OWIxLWJmMGUtNjU5OGNmNTZiNjE0%26state%3DCfDJ8HsL4swFLDZJp_SIAjZzOqKPpwz1Atc64KXujCT_rFX70jH8J7po6j99e5-gKFoTwQMHsRtFu9kQqvgbARRFPDVRF3qhN5Q4ednZ30-XkWwhtYelKxx-6WXBYNOvyH0V-VpKwI5lmRcuj9VVIjOu-9IPrj0eplKcoXF4qNt3OhMZJkb7zhVZFaQxK2sY3GUs0MAZeTiFUNzzVDa12aipCGcuacvOHaKerQp9HQTxpI3WGDjcVG3LkrBybjFyyiCmTyilPUUSeZxCw7Fx3DxymS4Xkq0TMXYlmZHxd1A_hINr%26x-client-SKU%3DID_NET10_0%26x-client-ver%3D8.16.0.0&RememberMe=False - 302 0 null 13.3468ms [22:53:12 INF] Request starting HTTP/2 GET https://localhost:44335/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3DPortal_BlazorServer%26redirect_uri%3Dhttps%253A%252F%252Flocalhost%253A44337%252Fsignin-oidc%26response_type%3Dcode%2520id_token%26scope%3Dopenid%2520profile%2520roles%2520email%2520phone%2520Portal%26response_mode%3Dform_post%26nonce%3D639160591679453180.NWM1MThkOTItODc3Ni00NTc4LWI2YTMtNjU1MzAxZGY2MDgxZmJjMjRmNWMtY2UwMC00OWIxLWJmMGUtNjU5OGNmNTZiNjE0%26state%3DCfDJ8HsL4swFLDZJp_SIAjZzOqKPpwz1Atc64KXujCT_rFX70jH8J7po6j99e5-gKFoTwQMHsRtFu9kQqvgbARRFPDVRF3qhN5Q4ednZ30-XkWwhtYelKxx-6WXBYNOvyH0V-VpKwI5lmRcuj9VVIjOu-9IPrj0eplKcoXF4qNt3OhMZJkb7zhVZFaQxK2sY3GUs0MAZeTiFUNzzVDa12aipCGcuacvOHaKerQp9HQTxpI3WGDjcVG3LkrBybjFyyiCmTyilPUUSeZxCw7Fx3DxymS4Xkq0TMXYlmZHxd1A_hINr%26x-client-SKU%3DID_NET10_0%26x-client-ver%3D8.16.0.0 - null null [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessRequestContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ResolveRequestUri. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ResolveRequestUri. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.OpenIddictServerHandlers+InferEndpointType. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by Volo.Abp.Account.Web.Pages.Account.OpenIddictImpersonateInferEndpointType. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ValidateTransportSecurityRequirement. [22:53:12 DBG] The event OpenIddict.Server.OpenIddictServerEvents+ProcessRequestContext was successfully processed by OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers+ValidateHostHeader. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ValidateHostHeader. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.OpenIddictValidationHandlers+EvaluateValidatedTokens. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromAuthorizationHeader. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromBodyForm. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractAccessTokenFromQueryString. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers+ExtractClientCertificate. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was successfully processed by OpenIddict.Validation.OpenIddictValidationHandlers+ValidateRequiredTokens. [22:53:12 DBG] The event OpenIddict.Validation.OpenIddictValidationEvents+ProcessAuthenticationContext was marked as rejected by OpenIddict.Validation.OpenIddictValidationHandlers+ValidateRequiredTokens. [22:53:12 DBG] AuthenticationScheme: OpenIddict.Validation.AspNetCore was not authenticated. [22:53:12 DBG] Starting resolving tenant... [22:53:12 DBG] Trying to resolve tenant through 'CurrentUser'... [22:53:12 DBG] Trying to resolve tenant through 'AbpAccount'... [22:53:12 DBG] Tenant resolved by 'AbpAccount' as 'Host'. [22:53:12 DBG] No tenant resolved. [22:53:12 INF] Executing endpoint '/Account/Login' [22:53:12 INF] Route matched with {page = "/Account/Login", area = "", action = "", controller = ""}. Executing page /Account/Login [22:53:12 INF] Skipping the execution of current filter as its not the most effective filter implementing the policy Microsoft.AspNetCore.Mvc.ViewFeatures.IAntiforgeryPolicy [22:53:12 INF] Executing handler method Volo.Abp.Account.Public.Web.Pages.Account.LoginModel.OnGetAsync - ModelState is Valid [22:53:12 INF] Executed handler method OnGetAsync, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult. [22:53:12 DBG] Added bundle 'LeptonX.Global' to the page in 0.35 ms. [22:53:12 DBG] Added bundle 'LeptonX.Global' to the page in 0.71 ms. [22:53:12 DBG] Added bundle 'Volo.Abp.Account.Public.Web.Pages.Account.LoginModel' to the page in 0.05 ms. [22:53:12 INF] Executed page /Account/Login in 57.424ms
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
I tried to reproduce this on the same setup (Shared user accounts + tiered + separate tenant database) and
Force change passwordon a tenant user works fine for me — so we're missing some specifics from your environment.Could you walk me through the exact steps to reproduce, starting from a fresh
abp newproject, plus a couple of details:- Exact ABP version (10.3.0 / 10.3.1 / ...)
- Does the tenant use its own connection string (Saas → Tenants → Connection Strings) or share the host DB?
- How was the tenant user created — invited, created by host admin via "Use as tenant", or seeded directly?
- Does
test.passwordexist in the host DB only, the tenant DB only, or both? If both, are theIds the same?
If easier, a minimal reproduction project would be the fastest path. Either invite https://github.com/maliming to a private GitHub repo, or zip and send via wetransfer to liming.ma@volosoft.com.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
To answer your questions:
- ABP version 10.3.0
- We use separate database per tenant, so yes the tenant has it's own connection string and database.
- We've tried all 3 scenarios, via invitation, host admin and seeded directly and they all fail. In this particular example what I did was:
- Create a new tenant from scratch.
- Login as an admin to the new tenant
- Invite a user
- User accepts the invitation
- As an admin in the tenant I enabled the
Force change passwordfor that user. - When the user tries to login it doesn't ask for password change and it doesn't let them login either. I need to uncheck the
force change passwordfor the user to login.
test.passwordwas invited to a specific tenant only (not the host) so it only exists in the tenant db and has a shadow record in the host database but with a different id. There are no records in the host users table fortest.passwordwith TenantId = NULL.
I'll try to reproduce this in a separate minimal project in case these details are not enough.
Thanks.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Thanks for the details. I tried the exact same setup on my side (10.3.0 source, separate tenant database, host shadow with a different Id from the tenant user) and
Force change passwordon the invited tenant user still goes through fine — so something else in your environment is different from a fresh template setup.A minimal reproduction project would really help. Either invite https://github.com/maliming to a private GitHub repo, or zip and send via wetransfer to liming.ma@volosoft.com.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
How can I create a solution in that specific version (10.3.0)? I've updated my ABP Studio and a new solution defaults to ABP 10.4.
In any case we're in the process of updating to ABP 10.4 so I figured I tried the same scenario in that version and it doesn't work properly either. In ABP 10.4 even though I toggle
force change passwordthe user is able to log in and is not required to change the password. I sent that sample through wetransfer and there's a video in the zip file of the test I did.Thanks.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Reproduced locally on 10.4 and got it down to two things:
The earlier
ChangePassword→Loginbounce.Abp.Account.Cookieis a short-lived purpose cookie that doesn't carry a security stamp, but the scheme hadSecurityStampValidatorwired on it. AfterSecurityStampValidatorOptions.ValidationIntervalelapses the validation always fails, the ticket is rejected, and the user is redirected back to Login. Default interval is 30 minutes, which is why it's intermittent. Fixed by removing the validator and disabling sliding expiration on this cookie.Force change passwordnot working from a tenant. In Shared User Accounts,IsActive/LockoutEnabled/ShouldChangePasswordOnNextLoginare host-only policy fields — the backend silently restores them when the request comes from a tenant context. The MVC / Blazor / Angular edit forms weren't reflecting that, so the checkbox looked editable and you could "save" it without any effect. Fixed by disabling those inputs in the tenant context so the UI matches the backend.
That said, you can only force-change-password a user that exists at the host level — i.e. has a host-scoped user record (either a host user with
TenantId = null, or a self-registered user withTenantId = Guid.Emptythat was later invited into the tenant). Users invited directly into a tenant may have tenant-scoped/shadow records, but they don't have a host-scoped user record that the host admin can manage for this policy. If you need this flow to work for tenant-invited users, the workflow is to invite them on the host side first. Supporting force-change-password for tenant-only users would be a separate product decision, not part of this fix.Both fixes ship in the next patch.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Thank you for the analysis and your response. Based on your response it seems like the work around to all our problems is to create host users for every tenant-scoped user so that we can manage those users from the host. The problem we have is that we don't want tenant-scoped users to be able to login to the host, only admin users should be allowed to login to the host. In order to fix our issues but also keep this functionality we would like to override the tenant selection options in both login and switch tenant options so that we can stop it from displaying "host" as an option unless the user is an admin in the host. We would like to do something like this:
After successful authentication if the user is not an admin:
- Check how many non-host tenants the user has been invited into
- If the user is in more than 1 non-host tenant, allow ABP to display the tenant selection UI (while hiding "host" as an option)
- If the user is in only 1 non-host tenant, automatically redirect them into that tenant
Can you provide guidance on how to override this behavior?
Thank you very much.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
I'd take a step back before writing the override for the SelectTenant / SwitchTenant filtering. Stepping back: your real goal is "the host admin can force-change-password for a tenant user, without giving that user the ability to log in to the host." If we go down the SelectTenant route, hiding
HostinSelectTenantModeland the Switch Tenant modal is just the UI half — you'd also need to make sure a direct POST or token grant cannot bypass that UI rule, which means enforcing the same check onSelectTenantModel.OnPostAsync,UserSharingAppService.GetAllListAsync,SwitchTenantLoginModel.OnPostAsync, and the OpenIddict and IdentityServerSwitchTenantextension grants. It's quite a bit of code to keep aligned, and you're spending it fencing off a host identity you only created to satisfy the policy management requirement.There's a cleaner way that doesn't require creating host records for tenant-invited users at all. Override
IdentityUserAppServiceso the host admin can see and edit tenant-scoped users directly from the host UI — the change is then written to the host shadow row and synced down to the tenant database in the same request. I verified this end-to-end against your reproduction setup (10.4, shared user accounts, separate tenant database): host admin lists a tenant-invited user, ticksShouldChangePasswordOnNextLogin, saves, and on next login that user goes straight to the ChangePassword screen.The override is one file in your
.Applicationproject:using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; using Volo.Abp.Application.Dtos; using Volo.Abp.Authorization; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Caching; using Volo.Abp.Data; 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.MultiTenancy; using Volo.Abp.ObjectMapping; using Volo.Abp.Threading; using Volo.Abp.Users; namespace YourApp; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(IIdentityUserAppService), typeof(IdentityUserAppService))] public class HostCrossTenantIdentityUserAppService : IdentityUserAppService { public HostCrossTenantIdentityUserAppService( 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) { } // Default: only members of the host "admin" role can manage tenant-scoped users. // Replace "admin" with your own host-side role name, or swap the check for a permission // check (await PermissionChecker.IsGrantedAsync("YourApp.ManageTenantScopedUsers")). // Without this gate, anyone with the default `IdentityPermissions.Users.Default` / // `Users.Update` permission would be able to read and modify users in every tenant. protected virtual async Task EnsureCanManageTenantScopedUsersAsync() { using (CurrentTenant.Change(null)) { var current = await UserManager.GetByIdAsync(CurrentUser.GetId()); if (!await UserManager.IsInRoleAsync(current, "admin")) { throw new AbpAuthorizationException(); } } } public override async Task<PagedResultDto<IdentityUserDto>> GetListAsync(GetIdentityUsersInput input) { if (UserSharingManager.IsEnabled() && CurrentTenant.Id == null) { await EnsureCanManageTenantScopedUsersAsync(); using (DataFilter.Disable<IMultiTenant>()) { return await base.GetListAsync(input); } } return await base.GetListAsync(input); } public override async Task<IdentityUserDto> GetAsync(Guid id) { if (UserSharingManager.IsEnabled() && CurrentTenant.Id == null) { await EnsureCanManageTenantScopedUsersAsync(); using (DataFilter.Disable<IMultiTenant>()) { return await base.GetAsync(id); } } return await base.GetAsync(id); } public override async Task<IdentityUserDto> UpdateAsync(Guid id, IdentityUserUpdateDto input) { if (UserSharingManager.IsEnabled() && CurrentTenant.Id == null) { await EnsureCanManageTenantScopedUsersAsync(); IdentityUser shadow; using (DataFilter.Disable<IMultiTenant>()) { shadow = await UserManager.FindByIdAsync(id.ToString()); } if (shadow != null && shadow.TenantId != null && shadow.TenantId != Guid.Empty) { // For tenant-scoped users the host admin only manages the host-only // policy fields. Name / email / roles / OUs stay tenant-managed, because // ABP's shared-mode user validator would otherwise treat the shadow row // and the tenant DB row as duplicates (different Ids). shadow.SetIsActive(input.IsActive); shadow.SetShouldChangePasswordOnNextLogin(input.ShouldChangePasswordOnNextLogin); (await ((IdentityProUserManager)UserManager).UpdateWithoutValidationAsync(shadow)).CheckErrors(); // The disable-filter scope ends above. Inside this tenant context the // multi-tenancy filter is back on and the connection is already scoped // to the target tenant, so the lookup stays inside it. using (CurrentTenant.Change(shadow.TenantId)) { var tenantUser = await UserManager.FindByEmailAsync(shadow.Email); if (tenantUser != null) { tenantUser.SetIsActive(shadow.IsActive); tenantUser.SetShouldChangePasswordOnNextLogin(shadow.ShouldChangePasswordOnNextLogin); (await ((IdentityProUserManager)UserManager).UpdateWithoutValidationAsync(tenantUser)).CheckErrors(); } } return ObjectMapper.Map<IdentityUser, IdentityUserDto>(shadow); } } return await base.UpdateAsync(id, input); } }How it works:
EnsureCanManageTenantScopedUsersAsyncis the security gate. Default check: the caller is in the host-sideadminrole. The defaultIdentityPermissions.Users.Default/Users.Updatepermissions are not enough by themselves — without this extra gate, anyone holding those would be able to read and modify users across every tenant. Swap the role check for your own role name or for a host-only permission check if that fits your model better.GetListAsync/GetAsyncin host context disable the multi-tenancy filter so the host admin sees tenant-invited users alongside host users.- For tenant-scoped users
UpdateAsynconly writes the host-only policy fields (IsActive,ShouldChangePasswordOnNextLogin) on the host shadow row, then enters the target tenant context (the filter is back on at that point) and syncs the same fields to the tenant database row soPreSignInChecksees them on the next login. - We use
UpdateWithoutValidationAsyncbecause ABP's shared-mode user validator treats the host shadow row and the tenant DB row as separate users (different Ids) and would otherwise reportUsername/Email is already takenwhen you save. - Name / email / roles / OUs for a tenant-scoped user keep going through the tenant admin path; the host admin only touches the policy flags.
- This works on top of the next-patch fixes (cookie loop and the host-only field UI disable) and doesn't conflict with them.
A few notes so this stays maintainable:
- This is an application-level customization, not a standard ABP pattern. Host admin reading and editing tenant-scoped user rows isn't the way ABP models user management in shared mode by default, and the validator behavior we're routing around is a known side effect of shadow rows using a different
Idfrom the tenant row. If a future ABP version changes how shared-mode user validation or host/tenant user replication works, this override will need a second look. - It only applies to existing shadow rows (i.e. users who accepted a tenant invitation). For users with no shadow row, the override falls through to
base.UpdateAsync. - Other host-side user actions are not extended by this override. Once the host user list contains tenant-scoped users, the default UI still surfaces actions like Roles / OUs / Claims / Permissions / Set Password / Two-Factor / Lock / Unlock / Sessions / Impersonation. Most of those will fall through to
baseand fail naturally on the shadow row, but if any of them silently succeed in your customization, hide them in the UI for tenant-scoped rows or deny them in your own app service override. - Audit anything custom you have layered on top of
IdentityUserAppService.GetListAsync— role / OU advanced filters, exports, custom list columns. When the multi-tenancy filter is disabled, those filters are interpreted against the cross-tenant dataset, so they can return cross-tenant rows or expose cross-tenant column values that the original UI didn't expect. - The sync uses
FindByEmailAsync(shadow.Email)to locate the tenant DB row, which relies on email being kept in sync between the shadow and the tenant row. That holds for the shared-accounts invitation flow; if you have customizations that allow email to drift between the two, you'll want a different lookup. LockoutEnabledisn't in the policy sync above —IdentityUser.LockoutEnabledhas a protected setter, and lockout state is naturally a tenant-side runtime concern (LockoutEnd/AccessFailedCountlive there). If you really need host-driven lockout policy too, you'll have to extend this override viaIUserLockoutStore<IdentityUser>on the user store.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
The approach you shared sounds easier to implement but it also sounds like it could more easily break as new versions of ABP are released and it could be confusing for our admin users as well. For example:
- If a user is invited to more than one tenant, I believe this override would list that user more than once in the users admin page.
- if in the future shadow rows no longer have different ids, this will break.
- We would also need to override the UI to remove actions that were not overriden here or that are tenant-specific to avoid confusions.
Main concern for us here is, we don't want to have to keep updating this logic as it breaks with future ABP updates. It seems to us like going the
every user in every tenant must have a host userroute so that the host admin can manage host only policies to all users would be less problematic.Considering that, our idea was to always create a host user for any tenant-only user and assign those users a
Tenant Onlyrole. In the UI, after authentication if the user has theTenant Onlyrole we do not displayHostas an option and we do the same onSwitch Tenant. We would also need to stop a user that tries to authenticate using APIs to theHostbut has aTenant Onlyrole.We don't really know how much code we would need to change for this but we figure if we do this once we wont have to worry about shared user accounts updates in the future.
Thanks.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
That makes sense — going with a host user +
Tenant Onlyrole gives you a stable seam that doesn't depend on the shadow-row Id behaviour or the user-validator side effects we were routing around in the previous reply.Good news: for the tenant-listing and tenant-switching paths, the host-access policy doesn't need overrides at five separate entry points. The SelectTenant page, the Switch Tenant modal, the cookie-based SwitchTenantLogin POST, the OpenIddict
SwitchTenantextension grant, and the IdentityServerSwitchTenantextension grant all route their authorization through just two methods onUserSharingManager:IsTenantSharedWithUserAsync(email, targetTenantId)— used by everything that switches into a tenant by id (SwitchTenantLoginModel.OnPostAsync, bothSwitchTenantextension grants)GetUserWithTenantsFromHostAsync(user, includeCurrentUser)— used by everything that lists the user's available tenants (SelectTenantModel.OnGetAsync/OnPostAsync,UserSharingAppService.GetAllListAsync)
Override those two and the host-only rule is enforced at every entry point that asks the framework "is this user allowed into this tenant?" or "what tenants is this user in?".
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.DistributedLocking; using Volo.Abp.Identity; using Volo.Abp.Identity.UserInvitations; using Volo.Abp.Identity.UserSharing; using Volo.Abp.MultiTenancy; namespace YourApp; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(UserSharingManager))] public class HostAccessRestrictedUserSharingManager : UserSharingManager { // Host-side role that marks a user as "must stay inside a tenant" — they can switch // between the tenants they belong to but can never end up signed in as a host user. // Replace with your own role name, your own permission check, or anything else that // expresses your host-access policy. public const string TenantOnlyRoleName = "Tenant Only"; public HostAccessRestrictedUserSharingManager( IOptions<AbpMultiTenancyOptions> multiTenancyOptions, IDataFilter dataFilter, ITenantStore tenantStore, IdentityProUserManager userManager, IIdentityUserRepository userRepository, IdentityRoleManager roleManager, IIdentityRoleRepository roleRepository, UserInvitationManager userInvitationManager, IAbpDistributedLock distributedLock, IConnectionStringResolver connectionStringResolver) : base(multiTenancyOptions, dataFilter, tenantStore, userManager, userRepository, roleManager, roleRepository, userInvitationManager, distributedLock, connectionStringResolver) { } // Returns true when the user is allowed to be signed in as a host user (i.e. with // TenantId == null). Default policy: the host-side user is NOT in the "Tenant Only" // role. If the user has no host record at all, there is no host login to grant, so // the method returns false. protected virtual async Task<bool> CanAccessHostAsync(string email) { if (email.IsNullOrWhiteSpace()) { return false; } using (CurrentTenant.Change(null)) { var hostUser = await UserManager.FindByEmailAsync(email); if (hostUser == null) { return false; } return !await UserManager.IsInRoleAsync(hostUser, TenantOnlyRoleName); } } public override async Task<bool> IsTenantSharedWithUserAsync(string email, Guid? targetTenantId) { if (targetTenantId == null && !await CanAccessHostAsync(email)) { return false; } return await base.IsTenantSharedWithUserAsync(email, targetTenantId); } public override async Task<List<UserSharingInfo>> GetUserWithTenantsFromHostAsync( IdentityUser user, bool includeCurrentUser = false) { var tenants = await base.GetUserWithTenantsFromHostAsync(user, includeCurrentUser); if (!await CanAccessHostAsync(user.Email)) { tenants = tenants.Where(t => t.TenantId != null).ToList(); } return tenants; } }I verified this end-to-end against your reproduction setup (10.4, shared user accounts, separate tenant database). With the override registered and a test user that has both a tenant shadow row and a host-side row in the
Tenant Onlyrole:- Logging in with that user's password lands directly inside the tenant rather than the host. The
Loginpage still callsShouldSelectTenantAsyncand decides to route throughSelectTenant, butSelectTenantModel.OnGetAsyncreads the filtered tenants list, sees a single entry, and auto-selects it. NoHostchoice is presented. - The user menu Switch Tenant modal loads its data from
UserSharingAppService.GetAllListAsync, which also goes throughGetUserWithTenantsFromHostAsync. With the filter applied, the modal does not include aHostentry. - The cookie
SwitchTenantLoginPOST and bothSwitchTenanttoken grants (OpenIddict and IdentityServer) all delegate the membership check toIsTenantSharedWithUserAsync, so a request that targetsTenantId == nullfor aTenant Onlyuser is rejected at the framework layer — there is no UI-only loophole.
A few notes:
- The host record still needs to exist for each tenant-invited user. You'll want to hook this into wherever you handle invitation acceptance (or run a one-off provisioning job) so that whenever a tenant-only user is created, you also create their host-side
AbpUsersrow withTenantId = null, link it to theTenant Onlyrole, and let ABP's existing distributed event handlers keepUserName/Email/PasswordHashin sync across the host row and the tenant row. - A tenant-only user will still hop through the
SelectTenantpage once.ShouldSelectTenantAsyncusesGetUsersByEmailFromHostAsync(not the override target) and seessharedUsers.Count > 1because the host record + the tenant record both count, so the login flow routes the user toSelectTenant. The override only kicks in inside the page:GetUserWithTenantsFromHostAsyncreturns the filtered list,SelectTenantModel.OnGetAsyncsees a single tenant entry, and auto-selects it. The end result is that the user lands inside their tenant — they just pass through a singleSelectTenanthop on the way there. You don't need to also overrideShouldSelectTenantAsyncto make this work. LeaveAsyncand other host-only management paths still go throughUserSharingAppServiceandUserSharingManager; if you add more host-only flows, route them through the sameCanAccessHostAsyncpolicy so the rule stays in one place.- If your host-access policy is more nuanced than a single role name (multiple roles, a permission, a claim, organization-unit gates),
CanAccessHostAsyncis the one place to express it — every entry point above already routes through it. - Scope of this override. It covers the tenant listing and tenant-switching flows (the SelectTenant page, the Switch Tenant modal, the cookie SwitchTenantLogin POST, both
SwitchTenantextension grants). It does not override the initial sign-in itself. A couple of paths to be aware of:Volo.Abp.Account.Public.Web.Areas.Account.Controllers.AccountController.Login(POST /api/account/login) callsPasswordSignInAsyncdirectly and does not pass throughShouldSelectTenantAsync—FindSharedUserByEmail/Namewill pick the host row first, so a tenant-only user would end up signed in as a host user. If your clients use this API endpoint, apply the same host-access check there as well (or block the host row from being chosen up-front).Login.cshtml.cs'sIsLinkLoginpath short-circuits to the linked-accounts redirect beforeShouldSelectTenantAsyncruns. If you use linked accounts, route them through the same policy.
- This is an application-level customization, not a standard ABP pattern. The default model is "if you have a host-side
AbpUsersrow you are a host user." We're keeping the host row for policy-management purposes but treating users in theTenant Onlyrole as "tenant-only" at the tenant listing / switching seams. If a future ABP version reshapes shared-mode tenant switching, this override will need a quick re-audit.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)