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
Hi,
TokenCleanupBackgroundWorker — runs every 1 hour (cleans up expired OpenIddict tokens older than 14 days)IdentitySessionCleanupBackgroundWorker — runs every 1 hour (cleans up inactive sessions older than 30 days)ExcelFileCleanupWorker — already runs daily at 23:00ExpiredAuditLogDeleterWorker — already runs daily at 23:00So only the first 2 are running hourly. Changing them to daily is totally fine — they're cleaning up data that's 14-30 days old, so a few hours delay doesn't matter at all.
You're right that the Hangfire dashboard is read-only for recurring jobs. You need to configure them in your code:
Configure<TokenCleanupOptions>(options =>
{
options.CleanupPeriod = (int)TimeSpan.FromDays(1).TotalMilliseconds;
});
Configure<IdentitySessionCleanupOptions>(options =>
{
options.CleanupPeriod = (int)TimeSpan.FromDays(1).TotalMilliseconds;
});
However, I should mention that these 4 workers are very unlikely to be the cause of your socket growth issue. Even at hourly frequency, they only create a few database connections per run. That's nothing compared to the ~120 sockets/minute growth you're seeing.
The socket issue is more likely caused by something else in your application — maybe manually created SqlConnection or HttpClient instances that aren't being disposed, or Azure SNAT port exhaustion. I'd recommend using dotnet-counters to monitor what's actually consuming the sockets:
dotnet-counters monitor --process-id <PID> --counters "Microsoft.Data.SqlClient.EventSource,System.Net.Sockets,System.Net.Http"
Thanks
Hi,
Yes, the Scriban vulnerability has been fixed. We addressed this in two steps:
Both fixes are included in version 10.2.0 and later.
Thanks