Activities of "maliming"

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 Host in SelectTenantModel and 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 on SelectTenantModel.OnPostAsync, UserSharingAppService.GetAllListAsync, SwitchTenantLoginModel.OnPostAsync, and the OpenIddict and IdentityServer SwitchTenant extension 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 IdentityUserAppService so 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, ticks ShouldChangePasswordOnNextLogin, saves, and on next login that user goes straight to the ChangePassword screen.

The override is one file in your .Application project:

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:

  • EnsureCanManageTenantScopedUsersAsync is the security gate. Default check: the caller is in the host-side admin role. The default IdentityPermissions.Users.Default / Users.Update permissions 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 / GetAsync in host context disable the multi-tenancy filter so the host admin sees tenant-invited users alongside host users.
  • For tenant-scoped users UpdateAsync only 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 so PreSignInCheck sees them on the next login.
  • We use UpdateWithoutValidationAsync because ABP's shared-mode user validator treats the host shadow row and the tenant DB row as separate users (different Ids) and would otherwise report Username/Email is already taken when 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 Id from 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 base and 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.
  • LockoutEnabled isn't in the policy sync above — IdentityUser.LockoutEnabled has a protected setter, and lockout state is naturally a tenant-side runtime concern (LockoutEnd / AccessFailedCount live there). If you really need host-driven lockout policy too, you'll have to extend this override via IUserLockoutStore<IdentityUser> on the user store.

Thanks

Hi,

There's no built-in Managed Identity option in the Azure blob provider yet — the config only takes a connection string. But you can plug in DefaultAzureCredential by overriding AzureBlobProvider, since GetBlobContainerClient is protected virtual. We may add native support for this in a future release.

Here's a working override (I verified it builds against Volo.Abp.BlobStoring.Azure 10.2.0 + Azure.Identity):

using System;
using Azure.Identity;
using Azure.Storage.Blobs;
using Volo.Abp.BlobStoring;
using Volo.Abp.BlobStoring.Azure;
using Volo.Abp.DependencyInjection;

namespace MyCompanyName.MyProjectName;

[Dependency(ReplaceServices = true)]
[ExposeServices(typeof(IBlobProvider), typeof(AzureBlobProvider))]
public class MyAzureBlobProvider : AzureBlobProvider
{
    public MyAzureBlobProvider(
        IAzureBlobNameCalculator azureBlobNameCalculator,
        IBlobNormalizeNamingService blobNormalizeNamingService)
        : base(azureBlobNameCalculator, blobNormalizeNamingService)
    {
    }

    protected override BlobContainerClient GetBlobContainerClient(BlobProviderArgs args)
    {
        var configuration = args.Configuration.GetAzureConfiguration();

        // Reuse the existing ConnectionString slot to carry the storage account URL,
        // e.g. "https://<account-name>.blob.core.windows.net".
        var serviceUri = new Uri(configuration.ConnectionString);
        var credential = new DefaultAzureCredential();

        var blobServiceClient = new BlobServiceClient(serviceUri, credential);

        return blobServiceClient.GetBlobContainerClient(GetContainerName(args));
    }
}

In your container configuration just put the storage account URL where the connection string used to go:

Configure<AbpBlobStoringOptions>(options =>
{
    options.Containers.ConfigureDefault(container =>
    {
        container.UseAzure(azure =>
        {
            azure.ConnectionString = "https://<your-account>.blob.core.windows.net";
            azure.ContainerName = "my-container";
            azure.CreateContainerIfNotExists = true;
        });
    });
});

Make sure the App Service's System-Assigned Managed Identity is granted the Storage Blob Data Contributor role on the storage account — that's what DefaultAzureCredential will use at runtime. If you want to be explicit you can swap new DefaultAzureCredential() for new ManagedIdentityCredential().

If you still see errors after this, set AZURE_LOG_LEVEL=verbose on the App Service to capture Azure.Identity diagnostic output and share the exception + relevant log snippet — we can take another look from there.

Thanks

Hi,

Reproduced locally on 10.4 and got it down to two things:

  1. The earlier ChangePassword → Login bounce. Abp.Account.Cookie is a short-lived purpose cookie that doesn't carry a security stamp, but the scheme had SecurityStampValidator wired on it. After SecurityStampValidatorOptions.ValidationInterval elapses 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.

  2. Force change password not working from a tenant. In Shared User Accounts, IsActive / LockoutEnabled / ShouldChangePasswordOnNextLogin are 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 with TenantId = Guid.Empty that 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

Great 👍

Thanks. I will check it asap.

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 password on 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

Hi,

I tried to reproduce this on the same setup (Shared user accounts + tiered + separate tenant database) and Force change password on 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 new project, plus a couple of details:

  1. Exact ABP version (10.3.0 / 10.3.1 / ...)
  2. Does the tenant use its own connection string (Saas → Tenants → Connection Strings) or share the host DB?
  3. How was the tenant user created — invited, created by host admin via "Use as tenant", or seeded directly?
  4. Does test.password exist in the host DB only, the tenant DB only, or both? If both, are the Ids 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

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

Hi,

Thanks for the version info. With ABP 10.1.1 + Microservice + Angular all the pieces should be in place by default — every *.HttpApi.Host in the microservice template ships with IsDynamicClaimsEnabled = true, and the Angular client calls /connect/revocat and /connect/endsession from inside angular-oauth2-oidc (in oAuthService.revokeTokenAndLogout()), so you won't see those calls in your own code. Both endpoints are wired on the server to revoke the IdentitySession.

Since the behavior on your side doesn't match that, could you share two things so we can see exactly what's happening:

  1. HAR file of the full logout flow — capture the network traffic from clicking logout until you land back on the login page. How to capture a HAR: https://abp.io/support/questions/8622/How-to-enable-Debug-logs-for-troubleshoot-problems#answer-3a1f7c9e-eb0b-eb9c-3535-1beae8e8ba43 — we need to see whether /connect/revocat and /connect/endsession are actually called and with what status.

  2. AuthServer Debug logs during the same logout. Set the global log level to Debug (rather than only a couple of categories) so we have full context to keep investigating if needed. How to enable Debug logs: https://abp.io/support/questions/8622/How-to-enable-Debug-logs-for-troubleshoot-problems

Also, one short note on how you're testing — are you doing it inside the Angular app (UI-driven logout, then making a call from the app), or are you copying the access token out and replaying it with Postman / curl?

The HAR and logs can be too big for the thread, please email them to liming.ma@volosoft.com and reference this question.

Thanks

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.Cookie purpose cookie is configured with SecurityStampValidator.ValidatePrincipalAsync as OnValidatePrincipal, but the cookie is written without a SecurityStamp claim. So once SecurityStampValidatorOptions.ValidationInterval elapses, 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 Web module's ConfigureServices until the next release:

PostConfigure<CookieAuthenticationOptions>(AccountPageModel.AccountCookieSchemeName, options =>
{
    options.Events.OnValidatePrincipal = ctx => Task.CompletedTask;
});

The purpose cookie is protected by its 10-minute ExpireTimeSpan and 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

Showing 401 to 410 of 12924 entries
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on November 02, 2026, 06:56
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.