Hi Aníbal,
Thanks for the detailed report. You're right that this looks like a license/module entitlement issue on our side rather than something for you to work around in the generated project.
We'll check and adjust the Personal license entitlement for the Volo.Abp.OperationRateLimiting.* packages on our end. I'll get back to you on this thread once it's done.
Thanks,
Hi,
Glad you spotted the difference — this is actually by design.
ABP runs an online license check only when ASPNETCORE_ENVIRONMENT=Development (or the debugger is attached). In any other environment (Staging, Production, etc.) only the offline check runs, which just needs the AbpLicenseCode value from configuration.
The online check expects the abp login token file (%UserProfile%\.abp\cli\access-token.bin on Windows, ~/.abp/cli/access-token.bin on Linux) to be present in the process's user home directory at runtime. That's why logging in on the dev PC and publishing didn't help — that file lives under your dev user profile, it's not part of the project, so dotnet publish doesn't include it. And even if you copied it into the Function, the token is bound to the developer machine identity, so it's not the right thing to ship to Azure anyway.
Recommendation for the Azure Function:
ASPNETCORE_ENVIRONMENT to Staging or Production in the Function App's Environment variables — never Development for a deployed environment.AbpLicenseCode is also set there (the long base64 string from your local appsettings.secrets.json).With those two settings, only the offline check runs and it will pass without needing any abp login state on the server.
Treat Development as something only your local dev machine ever uses — it assumes there is an interactive developer with an active abp login session.
Thanks
Hi,
ABP doesn't ship a dedicated "event-driven notification management" module that wires events to templates with a host-level on/off switch. But every building block you need is already in the framework, and composing them takes only a thin coordinator class. Here is the pattern we recommend.
public static class AppNotificationEvents
{
public const string UserRegistered = "App.Notifications.UserRegistered";
public const string OrderPlaced = "App.Notifications.OrderPlaced";
}
WithProviders("G") (= GlobalSettingValueProvider.ProviderName) restricts the setting to the host level, so tenants cannot override it.
public class AppNotificationSettingDefinitionProvider : SettingDefinitionProvider
{
public override void Define(ISettingDefinitionContext context)
{
context.Add(new SettingDefinition(
$"{AppNotificationEvents.UserRegistered}.EmailEnabled",
defaultValue: "true",
isVisibleToClients: true)
.WithProviders(GlobalSettingValueProvider.ProviderName));
context.Add(new SettingDefinition(
$"{AppNotificationEvents.OrderPlaced}.EmailEnabled",
defaultValue: "true",
isVisibleToClients: true)
.WithProviders(GlobalSettingValueProvider.ProviderName));
}
}
public class AppEmailTemplateDefinitionProvider : TemplateDefinitionProvider
{
public override void Define(ITemplateDefinitionContext context)
{
context.Add(new TemplateDefinition(
AppNotificationEvents.UserRegistered,
layout: StandardEmailTemplates.Layout,
localizationResource: typeof(AppResource))
.WithVirtualFilePath("/Emailing/Templates/UserRegistered.tpl", isInlineLocalized: true));
}
}
Add the Text Template Management module (Volo.Abp.TextTemplateManagement.*) to your solution. With it installed, host admins can edit the rendered content of every template through the UI; ITemplateRenderer checks the DB first and falls back to your .tpl file, so updates take effect without redeploy.
This is the single place every notification flows through.
public class NotificationDispatcher : IDomainService, ITransientDependency
{
protected ITemplateRenderer TemplateRenderer { get; }
protected ISettingProvider SettingProvider { get; }
protected IEmailSender EmailSender { get; }
public NotificationDispatcher(
ITemplateRenderer templateRenderer,
ISettingProvider settingProvider,
IEmailSender emailSender)
{
TemplateRenderer = templateRenderer;
SettingProvider = settingProvider;
EmailSender = emailSender;
}
public virtual async Task DispatchEmailAsync(
string eventCode, string to, string subject, object model, string? cultureName = null)
{
var enabled = await SettingProvider.GetAsync<bool>($"{eventCode}.EmailEnabled");
if (!enabled)
{
return;
}
var body = await TemplateRenderer.RenderAsync(eventCode, model, cultureName);
await EmailSender.QueueAsync(to, subject, body);
}
}
QueueAsync pushes the email into the ABP background job queue, so the calling event handler isn't blocked by SMTP.
Any local or distributed event handler (or just any application service) calls the dispatcher:
public class UserRegisteredEmailHandler
: ILocalEventHandler<UserRegisteredEto>, ITransientDependency
{
private readonly NotificationDispatcher _dispatcher;
public UserRegisteredEmailHandler(NotificationDispatcher dispatcher)
=> _dispatcher = dispatcher;
public Task HandleEventAsync(UserRegisteredEto eventData)
=> _dispatcher.DispatchEmailAsync(
AppNotificationEvents.UserRegistered,
eventData.Email,
"Welcome",
new { eventData.UserName, eventData.TenantName });
}
Inside the .tpl file you reference the model fields ({{model.user_name}}, {{model.tenant_name}}, …) using Scriban or Razor depending on which engine you registered.
TemplateDefinition + the Text Template Management module gives admins a UI to edit template content.NotificationDispatcher is the single chokepoint that checks the switch, renders, and sends — keeps your business code clean.If you later need admins to add new event codes at runtime (without redeploy), you can promote the static class into a NotificationEvent entity (Code / TemplateName / IsEmailEnabled) and resolve it from DB inside the dispatcher — but most teams don't need that and the static approach is much simpler.
Thanks
Hi,
The error ABP-LIC-ERROR - License check failed for 'Volo.Abp.OpenIddict.Pro.Domain-v10.0.2.0' means the running process can't find your license code at startup. It's not related to the AI Management module — any ABP commercial module (OpenIddict.Pro, Identity.Pro, etc.) does the same check. That's also why rolling back to the previous code didn't help: the issue is on the deployment side, not in your code.
ABP reads the license code from configuration with the key AbpLicenseCode. In a project generated by ABP Studio, this value lives in appsettings.secrets.json next to each host project (e.g. *.Web.Host, *.AuthServer, *.HttpApi.HostWithIds):
{
"AbpLicenseCode": "PABMAGkAYwBlAG4Acw...=="
}
A couple of quick questions to confirm where the gap is:
appsettings.secrets.json with an AbpLicenseCode value? (If yes, that's the value we need.)For Azure Functions, appsettings.secrets.json usually doesn't get picked up automatically (it's not part of the default Function host configuration, and even if you publish the file, the Functions runtime won't load it unless you wire it up explicitly). The cleanest fix is to set AbpLicenseCode as an Application Setting on the Function App itself:
AbpLicenseCodeappsettings.secrets.jsonASP.NET Core's environment variables provider exposes this to IConfiguration["AbpLicenseCode"], so the license check will pass without depending on whether the secrets file gets published.
If you can't find AbpLicenseCode anywhere in your local solution, let me know and I'll help you locate / regenerate it.
Thanks
Hi,
One clarification first: the framework does not leave orphaned references behind. Looking at OrganizationUnitAppService.DeleteAsync:
await UserManager.UpdateOrganizationAsync(id, null); // unlinks all users from this OU
await OrganizationUnitManager.DeleteAsync(id); // removes members + roles + child OUs, then the OU itself
The first call runs an ExecuteDelete over IdentityUserOrganizationUnit for the OU. The manager then recursively removes child OU memberships/roles before deleting the OU. So the link tables are actively cleaned up — no dangling rows.
The intentional design is "delete = auto-cleanup". Our own MVC and Blazor delete dialogs already wrap the API with this safeguard:
UserCount first.UserCount > 0, force the operator to choose: Unassign the users (default), or move them to another OU they pick from a list.MoveAllUsersAsync(id, targetOuOrNull) followed by DeleteAsync(id).You can see this in Volo.Abp.Identity.Pro.Web/Pages/Identity/OrganizationUnits/DeleteModal.cshtml(.cs) and the equivalent Blazor component. Calling DELETE directly from Swagger skips that prompt but lands at the same final state as picking "Unassign" in the UI — there's no orphaned data.
If your business rule is "API calls must explicitly clean up first, otherwise reject", that's a policy on top of the framework. The cleanest approach is to follow the same pattern as our UI from your client (check membership, decide, then call), or enforce it server-side by overriding the Pro app service:
[ExposeServices(typeof(IOrganizationUnitAppService))]
[Dependency(ReplaceServices = true)]
public class MyOrganizationUnitAppService : OrganizationUnitAppService
{
public MyOrganizationUnitAppService(
OrganizationUnitManager organizationUnitManager,
IdentityUserManager userManager,
IOrganizationUnitRepository organizationUnitRepository,
IIdentityUserRepository identityUserRepository,
IIdentityRoleRepository identityRoleRepository,
IdentityProTwoFactorManager identityProTwoFactorManager)
: base(organizationUnitManager, userManager, organizationUnitRepository,
identityUserRepository, identityRoleRepository, identityProTwoFactorManager)
{
}
public override async Task DeleteAsync(Guid id)
{
var ou = await OrganizationUnitRepository.GetAsync(id, includeDetails: true);
var memberCount = await OrganizationUnitRepository.GetMembersCountAsync(ou, includeChildren: true);
if (memberCount > 0)
{
throw new UserFriendlyException(
$"Cannot delete organization unit '{ou.DisplayName}' because it has {memberCount} assigned user(s). Please unassign or move them first.");
}
if (ou.Roles.Any())
{
throw new UserFriendlyException(
$"Cannot delete organization unit '{ou.DisplayName}' because it has assigned role(s). Please remove them first.");
}
await base.DeleteAsync(id);
}
}
GetMembersCountAsync(..., includeChildren: true) covers users attached to any descendant OU as well, so deletion is blocked even if only a child OU has members. ou.Roles comes from the aggregate (an embedded collection on OrganizationUnit), so includeDetails: true is required when loading.
Thanks.
Hi,
Thanks for the detailed report — you found a real framework gap. Confirmed in OrganizationUnitManager.CreateAsync and MoveAsync: neither method validates that the supplied parentId belongs to the current tenant, so calling the API with a ParentId from another tenant goes through.
What actually ends up in the DB is a bit subtle: the new OU's TenantId is correctly set to your current tenant (Tenant A), and because the multi-tenant filter hides Tenant B's OU from the lookup, the Code is calculated as a top-level code (00001). But the ParentId column is stored as-is, pointing at Tenant B's OU id. That cross-tenant FK reference is the real data-integrity issue (and MoveAsync has the same gap).
We just pushed the fix in https://github.com/abpframework/abp/pull/25357 — the manager now validates the parent tenant for both create and move and throws Volo.Abp.Identity:010010 (OrganizationUnitParentTenantMismatch) when they don't match. It will ship in the next ABP release.
For now, you can apply the same check from your application layer by overriding the Pro OrganizationUnitAppService:
[ExposeServices(typeof(IOrganizationUnitAppService))]
[Dependency(ReplaceServices = true)]
public class MyOrganizationUnitAppService : OrganizationUnitAppService
{
public MyOrganizationUnitAppService(
OrganizationUnitManager organizationUnitManager,
IdentityUserManager userManager,
IOrganizationUnitRepository organizationUnitRepository,
IIdentityUserRepository identityUserRepository,
IIdentityRoleRepository identityRoleRepository,
IdentityProTwoFactorManager identityProTwoFactorManager)
: base(organizationUnitManager, userManager, organizationUnitRepository,
identityUserRepository, identityRoleRepository, identityProTwoFactorManager)
{
}
public override async Task<OrganizationUnitWithDetailsDto> CreateAsync(OrganizationUnitCreateDto input)
{
await EnsureParentInCurrentTenantAsync(input.ParentId);
return await base.CreateAsync(input);
}
public override async Task MoveAsync(Guid id, OrganizationUnitMoveInput input)
{
await EnsureParentInCurrentTenantAsync(input.NewParentId);
await base.MoveAsync(id, input);
}
protected virtual async Task EnsureParentInCurrentTenantAsync(Guid? parentId)
{
if (!parentId.HasValue)
{
return;
}
var parent = await OrganizationUnitRepository.FindAsync(parentId.Value);
if (parent == null || parent.TenantId != CurrentTenant.Id)
{
throw new BusinessException("Identity:CrossTenantOrganizationUnitParent")
.WithData("ParentId", parentId);
}
}
}
Use FindAsync (not GetAsync) so the multi-tenant filter naturally hides cross-tenant parents — you'll get a clean BusinessException either way.
Thanks again for catching this.
Hi,
OpenIddict (and ABP's TokenController.HandleClientCredentialsAsync) only puts the scopes the client explicitly asked for into the access token. The scopes you tick on the application page are an allow-list, not defaults — if the client sends no scope parameter, the issued token has no scope at all, which is why your [Authorize] check fails.
OAuth 2.0 actually allows the authorization server to fall back to a pre-defined default scope (RFC 6749 §3.3 and §4.4.2), but ABP doesn't ship a built-in switch for that. The cleanest way to add it is a small IAbpOpenIddictClaimsPrincipalHandler that, on client_credentials requests with an empty scope, copies the application's oi_scp: permissions into the principal:
using System.Collections.Immutable;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using OpenIddict.Abstractions;
using Volo.Abp.DependencyInjection;
using Volo.Abp.OpenIddict;
public class DefaultClientCredentialsScopeHandler : IAbpOpenIddictClaimsPrincipalHandler, ITransientDependency
{
public ILogger<DefaultClientCredentialsScopeHandler> Logger { get; set; }
= NullLogger<DefaultClientCredentialsScopeHandler>.Instance;
public async Task HandleAsync(AbpOpenIddictClaimsPrincipalHandlerContext context)
{
var request = context.OpenIddictRequest;
if (!string.Equals(request.GrantType, OpenIddictConstants.GrantTypes.ClientCredentials, StringComparison.Ordinal))
{
return;
}
if (!context.Principal.GetScopes().IsDefaultOrEmpty)
{
return; // client requested scopes explicitly, leave it alone
}
var clientId = request.ClientId;
if (string.IsNullOrEmpty(clientId))
{
return;
}
var applicationManager = context.ScopeServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var scopeManager = context.ScopeServiceProvider.GetRequiredService<IOpenIddictScopeManager>();
var application = await applicationManager.FindByClientIdAsync(clientId);
if (application == null)
{
return;
}
var permissions = await applicationManager.GetPermissionsAsync(application);
var prefix = OpenIddictConstants.Permissions.Prefixes.Scope;
var scopes = permissions
.Where(p => p.StartsWith(prefix, StringComparison.Ordinal))
.Select(p => p[prefix.Length..])
.ToImmutableArray();
if (scopes.IsDefaultOrEmpty)
{
return;
}
Logger.LogInformation("Injecting configured scopes for {ClientId}: {Scopes}", clientId, string.Join(", ", scopes));
context.Principal.SetScopes(scopes);
var resources = new List<string>();
await foreach (var resource in scopeManager.ListResourcesAsync(scopes))
{
resources.Add(resource);
}
context.Principal.SetResources(resources);
}
}
Register it in your module's ConfigureServices:
Configure<AbpOpenIddictClaimsPrincipalOptions>(options =>
{
options.ClaimsPrincipalHandlers.Add<DefaultClientCredentialsScopeHandler>();
});
One thing to watch out for: register it with Configure, not PreConfigure. PreConfigure runs before the framework's own Configure<AbpOpenIddictClaimsPrincipalOptions> block in AbpOpenIddictAspNetCoreModule, and you'll find the handler gets dropped from the final list. I verified the whole flow locally on rel-10.3 against your entervo_infinite-style client (client_credentials + oi_scp:ConnectorNL permission):
scope → token comes back with "scope": "ConnectorNL" and "aud": "ConnectorNLResource" (handler kicks in).scope=ConnectorNL → identical token (handler is a no-op).We'll also look into making this a first-class option on the OpenIddict module in a future release, so you don't have to maintain the handler yourself.
Thanks
Hi,
Yes, that's expected — both services do register the AccountSettings controller because both IdentityService.HttpApi and AuthServer declare a dependency on AbpAccountAdminHttpApiModule. This is leftover from the older Ocelot template, where the gateway used to route /api/account-admin/... to IdentityService (port 44388). Starting from ABP 8.0 the gateway route was moved to AuthServer, but the dependency on the IdentityService side was kept in the template, so both services still surface the same endpoints in their swagger.
Functionally it doesn't cause any issue:
/api/account-admin/{**catch-all} to the AuthServer cluster, so the controller copy living inside IdentityService is never reached through the gateway.IdentityService swagger — no code change needed on your side.If you really want to clean it up so IdentityService no longer exposes those endpoints, you can remove the Account Admin references from the IdentityService projects (IdentityService.HttpApi.csproj / IdentityService.Application.csproj and the matching [DependsOn(...)] entries in their module files). But this is purely cosmetic — keeping them as-is matches the current ABP template and is perfectly fine.
Thanks