hi
We will add it to the download modules list.
Thanks.
Hi,
Since you're on 10.2+, shared accounts should already ship with AbpAccountTenantResolveContributor that forces CurrentTenant = null during the 2FA flow (because the user is not yet fully authenticated when entering the 2FA code). So in theory the 2FA page should look up the host user and work fine.
Before I dig deeper, a few questions to narrow it down — could you check and share:
How do you resolve tenants? Especially: are you using domain / subdomain tenant resolution (e.g. tenant1.mydomain.com)? If yes, please share the related configuration (where you call options.Tenants, AddDomainTenantResolver, or anything that inserts a domain/subdomain contributor). If you inserted it before CurrentUserTenantResolveContributor, that will bypass the shared-mode guard above and CurrentTenant will be set to the tenant on the 2FA page, which breaks GetTwoFactorAuthenticationUserAsync.
Confirm UserSharingStrategy — please share your Configure<AbpMultiTenancyOptions>(...) block from staging. Staging and local may not be on the same configuration:
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
options.UserSharingStrategy = TenantUserSharingStrategy.Shared;
});
What is the login URL on staging? Is it the host domain, a tenant subdomain, or a dedicated account domain? And what about local?
Browser DevTools check — reproduce the issue on staging, then in DevTools → Application → Cookies, check:
.AspNetCore.Identity.TwoFactorUserId cookie written after you submit username/password?/Account/SendSecurityCode, is that cookie sent back in the request headers?Domain / SameSite / Secure differences vs. local?Debug logs from the failing request on staging — please temporarily enable Debug level logs and capture the logs from the moment you submit the login form until you get redirected back to the login page. See this guide: https://abp.io/support/questions/8622/How-to-enable-Debug-logs-for-troubleshoot-problems
CurrentTenant.Id values, any CheckCurrentTenant failures (e.g. ApplicationException: Current tenant is different than given tenant. CurrentTenant.Id: ..., given tenantId: ...), SignInManager/TwoFactor related log entries, and any warnings/exceptions around the redirect.Does this happen for every 2FA account on staging, or only some of them? And does a pure host user (not in any tenant) with 2FA work on staging?
My current suspicion is either (a) a domain/subdomain tenant resolver inserted before the shared-mode guard, or (b) a cookie issue (SameSite/Domain) on staging that loses the TwoFactorUserId cookie across the redirect. The debug logs + the answers above should tell us which one.
Thanks
Hi,
Thank you so much for the detailed investigation, this is very helpful. You're absolutely right — I've confirmed this is a bug in our Pro UserPasswordChangeRequestedEventHandler.
The root cause is that this handler does not call await IdentityOptions.SetAsync(); before validating the password. SetAsync() is what loads the tenant-specific identity settings (password policy, lockout, etc.) into the IdentityOptions instance. Without it, the handler falls back to ASP.NET Core Identity's default policy, which requires digit, uppercase, lowercase and non-alphanumeric characters — exactly what you observed. Other places like IdentityUserAppService.UpdatePasswordAsync do call SetAsync(), so only this distributed event handler is affected.
We'll fix it in the next version by adding the SetAsync() call at the beginning of HandleEventAsync.
You can replace the buggy handler in your *.Domain project by doing two things:
1. Add a replacement handler class in your *.Domain project:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
using Volo.Abp.EventBus.Distributed;
using Volo.Abp.Identity;
using Volo.Abp.Users;
namespace MyCompanyName.MyProjectName.Identity;
public class MyUserPasswordChangeRequestedEventHandler :
IDistributedEventHandler<UserPasswordChangeRequestedEto>,
ITransientDependency
{
public ILogger<MyUserPasswordChangeRequestedEventHandler> Logger { get; set; }
protected IIdentityUserRepository UserRepository { get; }
protected IdentityUserManager IdentityUserManager { get; }
protected IOptions<IdentityOptions> IdentityOptions { get; }
public MyUserPasswordChangeRequestedEventHandler(
IIdentityUserRepository userRepository,
IdentityUserManager identityUserManager,
IOptions<IdentityOptions> identityOptions)
{
Logger = NullLogger<MyUserPasswordChangeRequestedEventHandler>.Instance;
UserRepository = userRepository;
IdentityUserManager = identityUserManager;
IdentityOptions = identityOptions;
}
public async Task HandleEventAsync(UserPasswordChangeRequestedEto eventData)
{
try
{
await IdentityOptions.SetAsync();
if (!eventData.Password.IsNullOrEmpty())
{
var user = await UserRepository.FindByTenantIdAndUserNameAsync(eventData.UserName, eventData.TenantId);
if (user != null)
{
var errors = await ValidatePasswordAsync(user, eventData.Password);
if (errors.Any())
{
Logger.LogError("User password change failed: {userName}, reason: {reason}", eventData.UserName,
string.Join(";", errors.Select(e => e.Code)));
}
else
{
(await IdentityUserManager.RemovePasswordAsync(user)).CheckErrors();
(await IdentityUserManager.AddPasswordAsync(user, eventData.Password)).CheckErrors();
Logger.LogInformation("User password changed: {userName}", eventData.UserName);
}
}
}
}
catch (Exception e)
{
Logger.LogError("An error occurred while handling UserPasswordChangeRequestedEto: {message}", e.Message);
Logger.LogException(e);
}
}
private async Task<List<IdentityError>> ValidatePasswordAsync(IdentityUser user, string password)
{
var errors = new List<IdentityError>();
foreach (var v in IdentityUserManager.PasswordValidators)
{
var result = await v.ValidateAsync(IdentityUserManager, user, password);
if (!result.Succeeded && result.Errors.Any())
{
errors.AddRange(result.Errors);
}
}
return errors;
}
}
2. Unsubscribe the original handler in your *.Domain module's ConfigureServices:
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpDistributedEventBusOptions>(options =>
{
options.Handlers.Remove<UserPasswordChangeRequestedEventHandler>();
});
// ... your other configurations
}
The Remove call runs after AbpEventBusModule has populated the handlers list, so only your new handler will be subscribed to UserPasswordChangeRequestedEto. After this, the Set Password action from the Tenant management page will respect your tenant's password policy and actually change the password.
Regarding your other two points:
Set Password. The UI can't tell whether the background handler succeeded. We'll review this separately.PasswordValidators, which is why a simple password is accepted at creation time. We'll revisit this to make the behavior more consistent.Thanks again for the great bug report! Your ticket has been refunded. 👍
Hi,
Let me address your questions one by one:
1. Calling uow.SaveChangesAsync() without uow.CompleteAsync()
This is a bug. SaveChangesAsync() only flushes pending changes to the database within the current transaction — it does NOT commit. Only CompleteAsync() commits the transaction. If you skip CompleteAsync(), the using block will dispose the UoW and the transaction will be rolled back. So your changes are lost, but it's not a zombie transaction.
2. [UnitOfWork] attribute behavior
Yes, ABP automatically calls CompleteAsync() for you when the method returns successfully. You should NOT manually call CompleteAsync() in a [UnitOfWork] method. You only call it when you manually start a UoW via _unitOfWorkManager.Begin(...).
3. [UnitOfWork] + try/catch/throw
[UnitOfWork]
public virtual async Task Create30DaysReminderAsync(Guid fileId)
{
try { /* db ops */ }
catch { throw; }
}
When the exception propagates out of the method, ABP won't call CompleteAsync(), so the transaction rolls back correctly. The try/catch/throw is redundant here (same as not catching), but it does not break rollback behavior.
4. [UnitOfWork] + swallow exception (no rethrow)
[UnitOfWork]
public virtual async Task<SaveResult> UpdateDocumentsAsync(...)
{
try { /* db ops */ }
catch (Exception ex) { _logger.LogException(ex, LogLevel.Error); } // swallowed
}
Because the exception is swallowed, ABP thinks the method succeeded and calls CompleteAsync(), which commits the transaction. This means partial/inconsistent data can be committed. If you really want to swallow, you should explicitly call await CurrentUnitOfWork.RollbackAsync() inside the catch block, or rethrow the exception.
5. Will it create a zombie transaction?
No. As long as the UoW is wrapped in a using block (or managed by [UnitOfWork]), it will always be disposed properly — the underlying DbTransaction is released either via commit or rollback on disposal. A true zombie/orphan transaction would only happen if you bypass ABP's UoW (e.g., calling DbContext.Database.BeginTransaction() directly without disposing) or if the process crashes mid-transaction. What you're seeing is more likely long-held locks from long-running transactions, not zombie transactions.
I wrote an article that explains all of this in detail, please take a look:
https://abp.io/community/articles/understanding-transactions-in-abp-unit-of-work-0r248xsr
Thanks
Hi,
Yes, exactly. Once all Tour permissions have .RequireFeatures(TMSMSFeature.Tour), they will be hidden from the Permission Management UI when the TourModule.TourService feature is disabled.
Your diagnostic logs already confirmed this — the permissions with CheckerCount=1 were correctly filtered (IsEnabled=False).
You can remove the diagnostic file from your project now.
Thanks
Hi,
Disabling a feature alone does not automatically hide permissions. Each permission must explicitly declare .RequireFeatures() to be filtered.
From your diagnostic logs, here's what happens:
Permission WITH .RequireFeatures() — feature disabled → hidden ✓
TourService.TourMasterTransferTypes: CheckerCount=1, RequiredFeatures=[TourModule.TourService], IsEnabled=False
Permission WITHOUT .RequireFeatures() — feature disabled → still visible ✗
TourService.TourMasterRateTypes: CheckerCount=0, RequiredFeatures=(none), IsEnabled=True
The second permission has CheckerCount=0 — it doesn't know about the TourModule.TourService feature at all, so it's always visible regardless of the feature state.
To fix this, every permission that should be tied to the feature needs .RequireFeatures():
// This permission will be hidden when feature is disabled ✓
group.AddPermission("TourService.TourMasterRateTypes", L("..."))
.RequireFeatures(TMSMSFeature.Tour);
// This permission ignores feature state entirely ✗
group.AddPermission("TourService.TourMasterRateTypes", L("..."));
There's no group-level or module-level way to apply .RequireFeatures() to all permissions at once — it must be added to each permission individually.
Thanks
Hi,
Thanks for the detailed logs, they confirmed the root cause clearly.
The logs show that permissions with .RequireFeatures("TourModule.TourService") are correctly filtered (StateCheck.IsEnabled=False). The ones that were still showing had CheckerCount=0 — meaning they didn't have .RequireFeatures() at all. Glad you already fixed those on your end.
Regarding your question about EditionFeatureValueProvider returning null:
This is expected behavior. EditionFeatureValueProvider queries the AbpFeatureValues table for an explicit record with ProviderName='E' and ProviderKey='{editionId}'. If no record exists, it returns null.
When you open the Feature Management dialog for the "standard" edition, the UI shows the checkbox as unchecked — but that's just displaying the resolved default value ("false" from your code), not a value that was saved to the database. Since you never explicitly changed and saved the feature value for this edition, no record was written to AbpFeatureValues. That's why EditionFeatureValueProvider returns null and the chain falls through to DefaultValueFeatureValueProvider which returns "false".
The provider chain works like a cascading lookup:
"false" ← usedIf you want the edition to have an explicit value, you'd need to actually change the toggle in the Feature Management UI for that edition and click Save — that creates a record in the database.
About ABP Suite not adding .RequireFeatures() automatically — we'll discuss this internally as a potential improvement.
You can now remove the diagnostic file from your project.
Thanks
Hi,
Sorry about that, I provided code targeting a newer ABP version. Here is the corrected version for ABP 9.2.0 — the PermissionAppService constructor in 9.2.0 only has 4 parameters:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.Authorization.Permissions;
using Volo.Abp.Features;
using Volo.Abp.MultiTenancy;
using Volo.Abp.PermissionManagement;
using Volo.Abp.SimpleStateChecking;
namespace YourNamespace;
public class DiagnosticPermissionAppService : PermissionAppService
{
private readonly IFeatureChecker _featureChecker;
private readonly IFeatureDefinitionManager _featureDefinitionManager;
public DiagnosticPermissionAppService(
IPermissionManager permissionManager,
IPermissionDefinitionManager permissionDefinitionManager,
IOptions<PermissionManagementOptions> options,
ISimpleStateCheckerManager<PermissionDefinition> simpleStateCheckerManager,
IFeatureChecker featureChecker,
IFeatureDefinitionManager featureDefinitionManager)
: base(permissionManager, permissionDefinitionManager, options, simpleStateCheckerManager)
{
_featureChecker = featureChecker;
_featureDefinitionManager = featureDefinitionManager;
}
public override async Task<GetPermissionListResultDto> GetAsync(
string providerName, string providerKey)
{
Logger.LogWarning("[Diag-Perm] ══════ GetAsync START ══════");
Logger.LogWarning(
"[Diag-Perm] Context: providerName={ProviderName}, providerKey={ProviderKey}, " +
"CurrentTenant.Id={TenantId}, MultiTenancySide={Side}",
providerName, providerKey,
CurrentTenant.Id?.ToString() ?? "(null/host)",
CurrentTenant.GetMultiTenancySide());
await DiagnoseFeatureAsync("TourModule.TourService");
foreach (var group in await PermissionDefinitionManager.GetGroupsAsync())
{
var tourPerms = group.GetPermissionsWithChildren()
.Where(p => p.Name.Contains("Tour", StringComparison.OrdinalIgnoreCase))
.ToList();
if (!tourPerms.Any())
{
continue;
}
Logger.LogWarning(
"[Diag-Perm] Group '{GroupName}': {Count} Tour permissions found",
group.Name, tourPerms.Count);
foreach (var perm in tourPerms)
{
var checkerTypes = perm.StateCheckers
.Select(c => c.GetType().Name)
.ToList();
var featureNames = perm.StateCheckers
.OfType<RequireFeaturesSimpleStateChecker<PermissionDefinition>>()
.SelectMany(c => c.FeatureNames)
.ToList();
bool stateCheckResult;
try
{
stateCheckResult = await SimpleStateCheckerManager.IsEnabledAsync(perm);
}
catch (Exception ex)
{
Logger.LogError(ex, "[Diag-Perm] StateChecker EXCEPTION for '{Name}'", perm.Name);
stateCheckResult = false;
}
Logger.LogWarning(
"[Diag-Perm] Permission '{Name}': " +
"CheckerCount={CheckerCount}, CheckerTypes=[{Checkers}], " +
"RequiredFeatures=[{Features}], StateCheck.IsEnabled={IsEnabled}",
perm.Name,
perm.StateCheckers.Count,
checkerTypes.Any() ? string.Join(", ", checkerTypes) : "(none)",
featureNames.Any() ? string.Join(", ", featureNames) : "(none)",
stateCheckResult);
}
}
var result = await base.GetAsync(providerName, providerKey);
var visible = result.Groups
.SelectMany(g => g.Permissions)
.Where(p => p.Name.Contains("Tour", StringComparison.OrdinalIgnoreCase))
.Select(p => p.Name)
.ToList();
Logger.LogWarning(
"[Diag-Perm] Final output: {Count} Tour permissions visible: [{Names}]",
visible.Count,
visible.Any() ? string.Join(", ", visible) : "(none)");
Logger.LogWarning("[Diag-Perm] ══════ GetAsync END ══════");
return result;
}
private async Task DiagnoseFeatureAsync(string featureName)
{
try
{
var featureDef = await _featureDefinitionManager.GetOrNullAsync(featureName);
if (featureDef == null)
{
Logger.LogWarning(
"[Diag-Perm] Feature '{Name}' definition NOT FOUND!", featureName);
return;
}
Logger.LogWarning(
"[Diag-Perm] Feature '{Name}': DefaultValue='{Default}', " +
"IsAvailableToHost={IsAvailableToHost}, AllowedProviders=[{Providers}]",
featureName,
featureDef.DefaultValue ?? "(null)",
featureDef.IsAvailableToHost,
featureDef.AllowedProviders.Any()
? string.Join(", ", featureDef.AllowedProviders)
: "(all)");
var rawValue = await _featureChecker.GetOrNullAsync(featureName);
var isEnabled = await _featureChecker.IsEnabledAsync(featureName);
Logger.LogWarning(
"[Diag-Perm] IFeatureChecker for '{Name}': raw='{RawValue}', enabled={IsEnabled}",
featureName, rawValue ?? "(null)", isEnabled);
}
catch (Exception ex)
{
Logger.LogError(ex, "[Diag-Perm] Error checking feature '{Name}'", featureName);
}
}
}
public class DiagnosticFeatureChecker : FeatureChecker
{
private readonly ICurrentTenant _currentTenant;
private readonly ILogger<DiagnosticFeatureChecker> _logger;
private const string DiagFilter = "Tour";
public DiagnosticFeatureChecker(
IOptions<AbpFeatureOptions> options,
IServiceProvider serviceProvider,
IFeatureDefinitionManager featureDefinitionManager,
IFeatureValueProviderManager featureValueProviderManager,
ICurrentTenant currentTenant,
ILogger<DiagnosticFeatureChecker> logger)
: base(options, serviceProvider, featureDefinitionManager, featureValueProviderManager)
{
_currentTenant = currentTenant;
_logger = logger;
}
public override async Task<string?> GetOrNullAsync(string name)
{
if (!name.Contains(DiagFilter, StringComparison.OrdinalIgnoreCase))
{
return await base.GetOrNullAsync(name);
}
_logger.LogWarning(
"[Diag-Feature] ── GetOrNullAsync('{Name}'), CurrentTenant.Id={TenantId} ──",
name, _currentTenant.Id?.ToString() ?? "(null/host)");
var featureDefinition = await FeatureDefinitionManager.GetOrNullAsync(name);
if (featureDefinition == null)
{
_logger.LogWarning(
"[Diag-Feature] Feature '{Name}' NOT FOUND → returning null", name);
return null;
}
_logger.LogWarning(
"[Diag-Feature] Feature '{Name}': DefaultValue='{Default}'",
name, featureDefinition.DefaultValue ?? "(null)");
var providers = FeatureValueProviderManager.ValueProviders.Reverse().ToList();
if (featureDefinition.AllowedProviders.Any())
{
providers = providers
.Where(p => featureDefinition.AllowedProviders.Contains(p.Name))
.ToList();
}
_logger.LogWarning(
"[Diag-Feature] Provider chain: [{Chain}]",
string.Join(" → ", providers.Select(p => p.Name + "(" + p.GetType().Name + ")")));
foreach (var provider in providers)
{
string? value;
try
{
value = await provider.GetOrNullAsync(featureDefinition);
}
catch (Exception ex)
{
_logger.LogError(ex,
"[Diag-Feature] Provider '{Provider}' threw exception!", provider.Name);
continue;
}
if (value != null)
{
_logger.LogWarning(
"[Diag-Feature] Provider '{Provider}' ({Type}) → '{Value}' ← WINNER",
provider.Name, provider.GetType().Name, value);
return value;
}
_logger.LogWarning(
"[Diag-Feature] Provider '{Provider}' ({Type}) → null",
provider.Name, provider.GetType().Name);
}
_logger.LogWarning("[Diag-Feature] All providers returned null");
return null;
}
}
Register both in your module's ConfigureServices:
context.Services.AddTransient<IPermissionAppService, DiagnosticPermissionAppService>();
context.Services.AddTransient<IFeatureChecker, DiagnosticFeatureChecker>();
After deploying, open the Permission Management dialog for the tenant and share the log output (search for [Diag-Perm] and [Diag-Feature]).
Thanks
Hi,
.RequireFeatures() on permissions should hide them from the Permission Management UI when the feature is disabled. The PermissionAppService.GetInternalAsync checks each permission's state checkers (including RequireFeaturesSimpleStateChecker) and filters out permissions whose required features are not enabled.
To figure out why this isn't working in your environment, I've prepared a diagnostic file with two service overrides. They add detailed logging so we can see exactly what's happening at each step.
Add this file to your Administration Service project:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.Authorization.Permissions;
using Volo.Abp.Features;
using Volo.Abp.MultiTenancy;
using Volo.Abp.PermissionManagement;
using Volo.Abp.SimpleStateChecking;
namespace YourNamespace;
// ═══════════════════════════════════════════════════════════════════
// HOW TO USE:
//
// Add this file to your Administration Service project, then register
// in your module's ConfigureServices:
//
// // 1. Permission Management diagnostics
// context.Services.AddTransient<IPermissionAppService, DiagnosticPermissionAppService>();
//
// // 2. Feature Checker diagnostics (logs every provider resolution)
// context.Services.AddTransient<IFeatureChecker, DiagnosticFeatureChecker>();
//
// Then open the Permission Management dialog for a tenant and check
// the application logs for lines starting with "[Diag-Perm]" and "[Diag-Feature]".
//
// REMOVE THIS FILE after troubleshooting is done.
// ═══════════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// 1. DiagnosticPermissionAppService
// Logs: context, StateCheckers on Tour permissions, final results
// ─────────────────────────────────────────────────────────────────
public class DiagnosticPermissionAppService : PermissionAppService
{
private readonly IFeatureChecker _featureChecker;
private readonly IFeatureDefinitionManager _featureDefinitionManager;
public DiagnosticPermissionAppService(
IPermissionManager permissionManager,
IPermissionChecker permissionChecker,
IPermissionDefinitionManager permissionDefinitionManager,
IResourcePermissionManager resourcePermissionManager,
IResourcePermissionGrantRepository resourcePermissionGrantRepository,
IOptions<PermissionManagementOptions> options,
ISimpleStateCheckerManager<PermissionDefinition> simpleStateCheckerManager,
IFeatureChecker featureChecker,
IFeatureDefinitionManager featureDefinitionManager)
: base(permissionManager, permissionChecker, permissionDefinitionManager,
resourcePermissionManager, resourcePermissionGrantRepository,
options, simpleStateCheckerManager)
{
_featureChecker = featureChecker;
_featureDefinitionManager = featureDefinitionManager;
}
protected override async Task<GetPermissionListResultDto> GetInternalAsync(
string groupName, string providerName, string providerKey)
{
Logger.LogWarning(
"[Diag-Perm] ══════ GetInternalAsync START ══════");
// ── Step 1: Log request context ──
Logger.LogWarning(
"[Diag-Perm] Context: providerName={ProviderName}, providerKey={ProviderKey}, " +
"CurrentTenant.Id={TenantId}, MultiTenancySide={Side}",
providerName, providerKey,
CurrentTenant.Id?.ToString() ?? "(null/host)",
CurrentTenant.GetMultiTenancySide());
// ── Step 2: Diagnose feature value directly ──
await DiagnoseFeatureAsync("TourModule.TourService");
// ── Step 3: Inspect Tour permission StateCheckers ──
foreach (var group in await PermissionDefinitionManager.GetGroupsAsync())
{
var tourPerms = group.GetPermissionsWithChildren()
.Where(p => p.Name.Contains("Tour", StringComparison.OrdinalIgnoreCase))
.ToList();
if (!tourPerms.Any())
{
continue;
}
Logger.LogWarning(
"[Diag-Perm] Group '{GroupName}': {Count} Tour permissions found",
group.Name, tourPerms.Count);
foreach (var perm in tourPerms)
{
var checkerTypes = perm.StateCheckers
.Select(c => c.GetType().Name)
.ToList();
var featureNames = perm.StateCheckers
.OfType<RequireFeaturesSimpleStateChecker<PermissionDefinition>>()
.SelectMany(c => c.FeatureNames)
.ToList();
bool stateCheckResult;
try
{
stateCheckResult = await SimpleStateCheckerManager.IsEnabledAsync(perm);
}
catch (Exception ex)
{
Logger.LogError(ex, "[Diag-Perm] StateChecker EXCEPTION for '{Name}'", perm.Name);
stateCheckResult = false;
}
Logger.LogWarning(
"[Diag-Perm] Permission '{Name}': " +
"CheckerCount={CheckerCount}, CheckerTypes=[{Checkers}], " +
"RequiredFeatures=[{Features}], StateCheck.IsEnabled={IsEnabled}",
perm.Name,
perm.StateCheckers.Count,
checkerTypes.Any() ? string.Join(", ", checkerTypes) : "(none)",
featureNames.Any() ? string.Join(", ", featureNames) : "(none)",
stateCheckResult);
}
}
// ── Step 4: Run original logic ──
var result = await base.GetInternalAsync(groupName, providerName, providerKey);
// ── Step 5: Log Tour permissions in final output ──
var visible = result.Groups
.SelectMany(g => g.Permissions)
.Where(p => p.Name.Contains("Tour", StringComparison.OrdinalIgnoreCase))
.Select(p => p.Name)
.ToList();
Logger.LogWarning(
"[Diag-Perm] Final output: {Count} Tour permissions visible: [{Names}]",
visible.Count,
visible.Any() ? string.Join(", ", visible) : "(none)");
Logger.LogWarning("[Diag-Perm] ══════ GetInternalAsync END ══════");
return result;
}
private async Task DiagnoseFeatureAsync(string featureName)
{
try
{
var featureDef = await _featureDefinitionManager.GetOrNullAsync(featureName);
if (featureDef == null)
{
Logger.LogWarning(
"[Diag-Perm] Feature '{Name}' definition NOT FOUND in FeatureDefinitionManager!",
featureName);
return;
}
Logger.LogWarning(
"[Diag-Perm] Feature '{Name}': DefaultValue='{Default}', " +
"IsAvailableToHost={IsAvailableToHost}, AllowedProviders=[{Providers}]",
featureName,
featureDef.DefaultValue ?? "(null)",
featureDef.IsAvailableToHost,
featureDef.AllowedProviders.Any()
? string.Join(", ", featureDef.AllowedProviders)
: "(all)");
var rawValue = await _featureChecker.GetOrNullAsync(featureName);
var isEnabled = await _featureChecker.IsEnabledAsync(featureName);
Logger.LogWarning(
"[Diag-Perm] IFeatureChecker.GetOrNullAsync('{Name}')='{RawValue}', " +
"IsEnabledAsync={IsEnabled}",
featureName,
rawValue ?? "(null)",
isEnabled);
}
catch (Exception ex)
{
Logger.LogError(ex, "[Diag-Perm] Error checking feature '{Name}'", featureName);
}
}
}
// ─────────────────────────────────────────────────────────────────
// 2. DiagnosticFeatureChecker
// Logs every provider in the resolution chain so we can see
// exactly which provider returns a value and what that value is.
// ─────────────────────────────────────────────────────────────────
public class DiagnosticFeatureChecker : FeatureChecker
{
private readonly ICurrentTenant _currentTenant;
private readonly ILogger<DiagnosticFeatureChecker> _logger;
// Only log details for features containing this keyword to avoid noise
private const string DiagFilter = "Tour";
public DiagnosticFeatureChecker(
IOptions<AbpFeatureOptions> options,
IServiceProvider serviceProvider,
IFeatureDefinitionManager featureDefinitionManager,
IFeatureValueProviderManager featureValueProviderManager,
ICurrentTenant currentTenant,
ILogger<DiagnosticFeatureChecker> logger)
: base(options, serviceProvider, featureDefinitionManager, featureValueProviderManager)
{
_currentTenant = currentTenant;
_logger = logger;
}
public override async Task<string?> GetOrNullAsync(string name)
{
if (!name.Contains(DiagFilter, StringComparison.OrdinalIgnoreCase))
{
return await base.GetOrNullAsync(name);
}
_logger.LogWarning(
"[Diag-Feature] ── GetOrNullAsync('{Name}') START, CurrentTenant.Id={TenantId} ──",
name, _currentTenant.Id?.ToString() ?? "(null/host)");
var featureDefinition = await FeatureDefinitionManager.GetOrNullAsync(name);
if (featureDefinition == null)
{
_logger.LogWarning(
"[Diag-Feature] Feature '{Name}' definition NOT FOUND → returning null",
name);
return null;
}
_logger.LogWarning(
"[Diag-Feature] Feature '{Name}' found: DefaultValue='{Default}'",
name, featureDefinition.DefaultValue ?? "(null)");
var providers = FeatureValueProviderManager.ValueProviders.Reverse().ToList();
if (featureDefinition.AllowedProviders.Any())
{
providers = providers
.Where(p => featureDefinition.AllowedProviders.Contains(p.Name))
.ToList();
_logger.LogWarning(
"[Diag-Feature] AllowedProviders filter active: [{Providers}]",
string.Join(", ", featureDefinition.AllowedProviders));
}
_logger.LogWarning(
"[Diag-Feature] Provider chain (in evaluation order): [{Chain}]",
string.Join(" → ", providers.Select(p => p.Name + "(" + p.GetType().Name + ")")));
// Manually iterate providers to log each step
foreach (var provider in providers)
{
string? value;
try
{
value = await provider.GetOrNullAsync(featureDefinition);
}
catch (Exception ex)
{
_logger.LogError(ex,
"[Diag-Feature] Provider '{Provider}' threw exception!", provider.Name);
continue;
}
if (value != null)
{
_logger.LogWarning(
"[Diag-Feature] Provider '{Provider}' ({Type}) returned '{Value}' ← WINNER",
provider.Name, provider.GetType().Name, value);
return value;
}
_logger.LogWarning(
"[Diag-Feature] Provider '{Provider}' ({Type}) returned null → next",
provider.Name, provider.GetType().Name);
}
_logger.LogWarning(
"[Diag-Feature] All providers returned null → returning null");
return null;
}
}
Register in your module's ConfigureServices:
context.Services.AddTransient<IPermissionAppService, DiagnosticPermissionAppService>();
context.Services.AddTransient<IFeatureChecker, DiagnosticFeatureChecker>();
After deploying, open the Permission Management dialog for the tenant where TourServiceModule is disabled. Then search your logs for [Diag-Perm] and [Diag-Feature].
The logs will tell us:
TourModule.TourService feature definition exists in the Administration ServiceIFeatureChecker returns and which provider (Tenant/Edition/Config/Default) provided itRequireFeaturesSimpleStateChecker in their StateCheckers listStateCheck.IsEnabled returns true or false for each Tour permissionPlease share the log output and we'll identify the root cause.
Thanks