Authorization¶
Authorization in Cargonerds is built on ABP's permission system. Permissions are declared as constant strings, registered into a permission tree by a PermissionDefinitionProvider, and enforced either declaratively with the [Authorize] attribute or imperatively through IAuthorizationService.
On top of the stock ABP model the solution adds two project-specific conventions:
- A custom
[GrantInRoles]attribute that declares, next to each permission constant, which roles receive it at seed time — with automatic roll-up through a role hierarchy. - A bespoke read/list base service (
HubEntityBaseService) that performs an imperative permission check before returning data, and permission-gated query includes that widen the result graph only when the caller may see the extra data.
Where things live
Permission definitions (the constants and providers) live in the *.Application.Contracts projects. The grant logic that maps permissions onto roles lives in src/Cargonerds.Domain (RolesDataSeedContributor). Enforcement lives in the *.Application services. Keep this split in mind — adding a permission constant is not enough for a role to receive it.
Concepts at a glance¶
flowchart TD
subgraph Contracts["*.Application.Contracts (definition)"]
C[Permission constants<br/>HubPermissions / CargonerdsPermissions / PricingPermissions]
P[PermissionDefinitionProvider<br/>builds the permission tree]
A["[GrantInRoles] attribute<br/>on each constant"]
C --> P
C --- A
end
subgraph Domain["Cargonerds.Domain (seeding)"]
S[RolesDataSeedContributor<br/>reflects over GrantInRoles]
end
subgraph App["*.Application (enforcement)"]
AUTH["[Authorize(permission)]"]
CHECK["IAuthorizationService.CheckAsync / IsGrantedAnyAsync"]
end
A -->|read by reflection at seed time| S
S -->|SetForRoleAsync| DB[(Permission grants<br/>per role)]
P -->|registers policies| AUTH
P -->|registers policies| CHECK
Permission definitions¶
A permission is just a string constant. The constants are organised into nested static classes so the value of a child encodes its parent (Hub.Shipment.View), and each *Permissions class exposes a reflection-based GetAll() via ReflectionHelper.GetPublicConstantsRecursively.
There are three permission groups, one per layer/module:
| Group | Constants | Provider | Project |
|---|---|---|---|
Cargonerds |
CargonerdsPermissions |
CargonerdsPermissionDefinitionProvider |
src/Cargonerds.Application.Contracts/Permissions/ |
Hub |
HubPermissions |
HubPermissionDefinitionProvider |
modules/hub/src/Hub.Application.Contracts/Permissions/ |
Pricing |
PricingPermissions |
PricingPermissionDefinitionProvider |
modules/pricing/src/Pricing.Application.Contracts/Permissions/ |
Hub permissions¶
The Hub module owns almost all business permissions. Note the [GrantInRoles] attributes (covered below):
// modules/hub/src/Hub.Application.Contracts/Permissions/HubPermissions.cs
public class HubPermissions
{
public const string GroupName = "Hub";
public static class Shipment
{
[GrantInRoles(RoleConsts.DefaultCustomer)]
public const string ShipmentGroup = GroupName + ".Shipment";
[GrantInRoles(RoleConsts.DefaultCustomer)]
public const string View = ShipmentGroup + ".View";
public const string Create = ShipmentGroup + ".Create";
public const string Edit = ShipmentGroup + ".Edit";
public const string Delete = ShipmentGroup + ".Delete";
}
public static class Insights
{
[GrantInRoles(RoleConsts.SuperUser)]
public const string InsightsGroup = GroupName + ".Insights";
// View, ViewFinancial, MyInsights.View, MyInsights.Edit (all SuperUser)
}
public static class RealtimeAdmin
{
// Gates the realtime admin page; value must be kept in sync with the frontend manually.
[GrantInRoles(RoleConsts.Operator)]
public const string Default = GroupName + ".RealtimeAdmin";
}
// Orders, Document, Invoice, Organization, Reports, Notification, CodeEntities, InternalAdmin ...
public static string[] GetAll() =>
ReflectionHelper.GetPublicConstantsRecursively(typeof(HubPermissions));
}
Constants are registered into the ABP permission tree by the provider, which uses AddGroup / AddPermission / AddChild and localizes each display name through the Hub localization resource:
// modules/hub/src/Hub.Application.Contracts/Permissions/HubPermissionDefinitionProvider.cs
public class HubPermissionDefinitionProvider : PermissionDefinitionProvider
{
public override void Define(IPermissionDefinitionContext context)
{
var hubGroup = context.AddGroup(HubPermissions.GroupName, L("Permission:Hub"));
var shipmentPermission = hubGroup.AddPermission(
HubPermissions.Shipment.View, L("Permission:Shipment:View"));
shipmentPermission.AddChild(HubPermissions.Shipment.Create, L("Permission:Shipment:Create"));
shipmentPermission.AddChild(HubPermissions.Shipment.Edit, L("Permission:Shipment:Edit"));
shipmentPermission.AddChild(HubPermissions.Shipment.Delete, L("Permission:Shipment:Delete"));
hubGroup.AddPermission(HubPermissions.Organization.View, L("Permission:Organization:View"));
// orders, documents, invoices, insights, reports, notifications, code entities ...
}
private static LocalizableString L(string name) =>
LocalizableString.Create<HubResource>(name);
}
The tree shape and the attribute defaults are independent
The parent/child structure in the provider only drives the permission-management UI (checking a parent suggests its children). It does not grant anything. Whether a role actually receives a permission is decided solely by [GrantInRoles] + RolesDataSeedContributor. The two can legitimately disagree — e.g. Shipment.Create is a child of Shipment.View in the tree but carries no [GrantInRoles], so no role gets it by seeding.
Core (Cargonerds) permissions¶
The host layer owns API-key management, dashboard tiles, and the custom-script setting. It also re-declares several ABP Identity permission strings purely so it can attach [GrantInRoles] to them for the seeder:
// src/Cargonerds.Application.Contracts/Permissions/CargonerdsPermissions.cs
public static class CargonerdsPermissions
{
public const string GroupName = "Cargonerds";
public static class ApiKeys
{
public const string Default = GroupName + ".ApiKeys";
public const string Create = Default + ".Create";
public const string Edit = Default + ".Edit";
public const string Delete = Default + ".Delete";
public const string ManagePermissions = Default + ".ManagePermissions";
// Manage API keys of any user (admin scope). Without it, users only see their own keys.
[GrantInRoles(RoleConsts.SuperUser)]
public const string ManageAll = Default + ".ManageAll";
}
public static class Users
{
[GrantInRoles(RoleConsts.Operator)]
public const string Default = IdentityPermissions.Users.Default; // aliases ABP's constant
[GrantInRoles(RoleConsts.AccountOwner)]
public const string SendPasswordMail = IdentityPermissions.Users.Default + ".SendPasswordMail";
// EditEmail, Create/Update/Delete, ManagePermissions, ManageRoles ...
}
// Dashboard (Host/Tenant), Settings.CustomScript, Comments, OrganizationUnits ...
}
Pricing permissions¶
The Pricing module shows the only use of Inherit = false in the solution — QuoteRequest is pinned to exactly DefaultCustomer and is deliberately not rolled up to higher roles:
// modules/pricing/src/Pricing.Application.Contracts/Permissions/PricingPermissions.cs
public static class Quotation
{
[GrantInRoles(RoleConsts.DefaultCustomer)] public const string View = QuotationGroup + ".View";
[GrantInRoles(RoleConsts.AccountOwner)] public const string Edit = QuotationGroup + ".Edit";
[GrantInRoles(RoleConsts.Operator)] public const string Admin = QuotationGroup + ".Admin";
[GrantInRoles(RoleConsts.DefaultCustomer, Inherit = false)]
public const string QuoteRequest = QuotationGroup + ".QuoteRequest";
}
Extending other modules' groups¶
CargonerdsPermissionDefinitionProvider does not only add its own group — it augments built-in ABP groups. It pulls Identity's Users and OrganizationUnits permissions and adds Cargonerds-specific children, and adds Comments.UpdateStatus under the CMS Kit group:
// src/Cargonerds.Application.Contracts/Permissions/CargonerdsPermissionDefinitionProvider.cs
var identityGroup = context.GetGroup(IdentityPermissions.GroupName);
var userGroup = identityGroup.GetPermissionOrNull(IdentityPermissions.Users.Default);
if (userGroup != null)
{
userGroup.AddChild(CargonerdsPermissions.Users.SendPasswordMail, L("Permission:Users:SendPasswordMail"));
userGroup.AddChild(CargonerdsPermissions.Users.EditEmail, L("Permission:User:EditEmail"));
// ...
}
// pulls a Hub permission into the Cargonerds group:
myGroup.AddPermission(HubPermissions.InternalAdmin.Default, L("Permission:InternalAdmin"));
// CmsKit group is fetched directly (no null guard):
var commentGroup = context.GetGroup(CmsKitPermissions.GroupName);
commentGroup.AddPermission(CargonerdsPermissions.Comments.UpdateStatus);
Unguarded CMS Kit group lookup
The Users / OrganizationUnits augmentation guards with GetPermissionOrNull(...) != null, but the CMS Kit comment group is fetched with context.GetGroup(CmsKitPermissions.GroupName) directly. If the CMS Kit module were ever removed from the dependency graph, that call would throw at application startup.
Default role grants: [GrantInRoles]¶
[GrantInRoles] is a custom attribute (Hub.Attributes.GrantInRolesAttribute, defined in modules/hub/src/Hub.Domain.Shared/Attributes/GrantInRolesAttribute.cs). It records, next to the permission constant, which roles should receive that permission at seed time:
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class GrantInRolesAttribute(params string[] roles) : Attribute
{
public string[] Roles { get; } = roles;
// If true (default), the permission is also granted to every role above the
// listed role(s) in RoleConsts.Hierarchy.
public bool Inherit { get; set; } = true;
}
Roles and their hierarchy (lowest → highest) are defined in RoleConsts (modules/hub/src/Hub.Domain.Shared/Consts/RoleConsts.cs):
public static class RoleConsts
{
public const string Admin = "admin"; // built-in ABP admin role
public const string DefaultCustomer = nameof(DefaultCustomer);
public const string Operator = nameof(Operator);
public const string AccountOwner = nameof(AccountOwner);
public const string LocalRealtimeAdmin = nameof(LocalRealtimeAdmin);
public const string SuperUser = nameof(SuperUser);
public static readonly string[] Hierarchy =
[
DefaultCustomer, Operator, AccountOwner, LocalRealtimeAdmin, SuperUser,
];
}
How seeding works¶
RolesDataSeedContributor (src/Cargonerds.Domain/RolesDataSeedContributor.cs) is an ABP data-seed contributor. On seeding (run by the DbMigrator) it:
- Creates every role in
RoleConsts.Hierarchyif missing, and marksDefaultCustomeras the default role. - Reflects over
HubPermissions,CargonerdsPermissions, andPricingPermissions(theAttributedPermissionTypesarray), recursing into nested classes to find everystringconstant carrying[GrantInRoles]. - For each, expands the role list upward through the hierarchy when
Inherit == true, then callsIPermissionManager.SetForRoleAsync(role, permission, true)for every(permission, role)pair.
private static string[] ResolveRoles(string[] roles, bool inherit)
{
if (!inherit) return roles;
var result = new HashSet<string>();
foreach (var role in roles)
{
var index = Array.IndexOf(RoleConsts.Hierarchy, role);
if (index < 0) { result.Add(role); continue; } // role not in hierarchy → as-is
for (var i = index; i < RoleConsts.Hierarchy.Length; i++)
result.Add(RoleConsts.Hierarchy[i]); // this role and every higher one
}
return result.ToArray();
}
So [GrantInRoles(RoleConsts.Operator)] grants the permission to Operator, AccountOwner, LocalRealtimeAdmin, and SuperUser. [GrantInRoles(..., Inherit = false)] pins it to exactly the listed role(s).
Why a custom attribute?
[GrantInRoles] keeps the default grant policy next to the permission definition and lets the seeder roll grants up the role hierarchy automatically — instead of maintaining a separate, hand-written role→permission matrix.
Grants apply only at seed time
Adding, removing, or changing a [GrantInRoles] attribute has no effect until the seeder runs again (re-run the DbMigrator). A permission constant with no [GrantInRoles] is granted to no role by seeding — it can still be granted manually through the permission-management UI. Because inheritance is upward and on by default, annotating a permission for a low role silently grants it to every higher role too.
Enforcement¶
ABP registers every defined permission name as an ASP.NET Core authorization policy. That is why both [Authorize("Perm")] and [Authorize(Policy = "Perm")] work and are equivalent — both are used in this codebase.
Declarative: class / method attributes¶
Apply [Authorize] at the class level (applies to all methods) and/or per method to narrow it:
// modules/hub/src/Hub.Application/Services/ShipmentAppService.cs (per-method form)
[Authorize(HubPermissions.Shipment.Edit)]
public async Task<CombinedShipmentDto> UpdateAsync(...) { /* ... */ }
// modules/hub/src/Hub.Application/Services/CodeEntities/CodeEntityBaseService.cs (class + policy form)
[Authorize] // class: authenticated users only
public abstract class CodeEntityBaseService<...> : HubEntityBaseService<...>
{
[Authorize(Policy = HubPermissions.CodeEntities.View)] // method: specific permission
public async Task<CodeEntitySettingDto> GetSettings() { /* ... */ }
[Authorize(HubPermissions.CodeEntities.Create)]
public async Task<TDto> CreateAsync(TDto dto) { /* ... */ }
}
The three useful shapes:
| Form | Meaning |
|---|---|
[Authorize] |
Any authenticated user (no specific permission). Used by HubOrganizationAppService, DashboardAppService, ProfileAppService, and as a class-level default on CodeEntityBaseService. |
[Authorize("Hub.Shipment.View")] / [Authorize(HubPermissions.Shipment.View)] |
Caller must hold that permission. |
[Authorize(Policy = HubPermissions.CodeEntities.View)] |
Identical to the above — the permission name is the policy name. |
[AllowAnonymous] |
Opt a method out of an enclosing [Authorize]. Used by CustomScriptSettingAppService.GetAsync because the script is needed on public pages. |
A failed [Authorize] results in an AbpAuthorizationException, surfaced to HTTP callers as 401 (not authenticated) or 403 (authenticated but not permitted).
Imperative: the ReadPermission hook¶
The custom read/list base HubEntityBaseService (modules/hub/src/Hub.Application/Services/HubEntityBaseService.cs) checks a permission before any data is read or returned. Subclasses expose it by overriding ReadPermission:
// HubEntityBaseService — the base hook
protected virtual string ReadPermission => string.Empty; // empty = no check
public virtual async Task<TDto?> GetAsync(string id)
{
await CheckPermissionAsync(ReadPermission);
// ... cache + map
}
protected Task CheckPermissionAsync(string permission)
{
if (!string.IsNullOrEmpty(permission))
return Get<IAuthorizationService>().CheckAsync(permission); // throws if not granted
return Task.CompletedTask;
}
// ShipmentAppService — concrete override
protected override string ReadPermission => HubPermissions.Shipment.View;
CheckPermissionAsync no-ops on an empty string and otherwise calls IAuthorizationService.CheckAsync, which throws AbpAuthorizationException when the permission is missing. This runs for both GetAsync(string id) and GetListAsync(input, force).
HubEntityBaseService is not ABP CrudAppService
Listing and reads flow through a bespoke pipeline (permission check → second-level cache → org-unit/tenant filtering → OData → facets), not ABP's CrudAppService. The ReadPermission hook is how that pipeline enforces read access. See Service Patterns for the full pipeline.
Imperative: conditional IsGrantedAnyAsync checks¶
When access is not all-or-nothing but should shape the result, services query IAuthorizationService.IsGrantedAnyAsync, which returns a bool instead of throwing. ShipmentAppService uses this for permission-gated includes — documents and invoices are only joined into the shipment graph when the caller may see them:
// modules/hub/src/Hub.Application/Services/ShipmentAppService.cs
protected override async Task<IQueryable<ShipmentBase>> WithDetailsAsync()
{
IQueryable<ShipmentBase> query = await base.WithDetailsAsync();
var filterCtx = Get<CurrentFilterContextProvider>();
return query
.IncludeFilteredDocumentsIf(
filterCtx,
await AuthorizationService.IsGrantedAnyAsync(HubPermissions.Document.View))
.IncludeInvoicesAndRevenuesIf(
filterCtx,
await AuthorizationService.IsGrantedAnyAsync(HubPermissions.Invoice.View));
}
| API | Returns | Use when |
|---|---|---|
IAuthorizationService.CheckAsync(permission) |
void — throws AbpAuthorizationException if denied |
Hard-gating an operation (used by CheckPermissionAsync). |
IAuthorizationService.IsGrantedAnyAsync(permission) |
bool |
Branching logic, conditional includes, optional UI affordances. |
Where data visibility comes from (beyond permissions)¶
Permissions answer "may this user perform this operation / see this entity type?" They do not scope which rows a user sees. Row-level visibility is layered on separately by organization-unit / tenant filtering (e.g. ShipmentAppService.IsAvailableInOrgUnit, CurrentFilterContextProvider.ActiveOrgIds). A user with Shipment.View still only sees shipments inside their active organization units. That mechanism is documented under the data-filtering material — see ABP Patterns.
Feature-based gating¶
ABP features (multi-tenant on/off and valued settings) are a separate concept from permissions: permissions are per-user/role, features are per-tenant/edition. In this codebase features are read imperatively through IFeatureChecker — there is no [RequiresFeature]-style attribute in the tree (verified: no RequiresFeature/RequireFeatures usage exists). Feature values are always strings, so the project centralizes parsing in extension methods:
// modules/hub/src/Hub.Domain/Extensions/FeatureCheckerExtensions.cs
public static async Task<bool> IsDetailedShipmentEmailsEnabledAsync(this IFeatureChecker fc) =>
await fc.IsEnabledAsync(HubFeatures.DetailedEmails.Enabled)
&& await fc.IsEnabledAsync(HubFeatures.DetailedEmails.Shipments);
The second-level cache on HubEntityBaseService is itself feature-gated: IsCacheEnabledAsync checks HubFeatures.Cache.Enabled, and the TTL / background-refresh options are read from HubFeatures.Cache.*. So a feature can switch read-path caching on or off per tenant without touching authorization.
Permissions vs. features — don't conflate them
Use a permission to gate who may do something. Use a feature to gate whether a tenant/edition has the capability at all. They compose: a service can require [Authorize(SomePermission)] and internally short-circuit on IFeatureChecker.IsEnabledAsync(...).
Project-memory note about an Hub.Ai.ApiKey feature is stale
Earlier project notes reference an AI feature gated via IFeatureChecker (Hub.Ai.ApiKey, ClaudeAiAssistant). No such feature, setting, or client exists in the current tree. Feature gating that does exist is described above (detailed emails, cache). The full feature/setting catalogue and the isVisibleToClients frontend-exposure flag are documented elsewhere — see Configuration Reference.
API keys: permissions capped by the owner¶
The custom API-key authentication subsystem (in src/Cargonerds.HttpApi.Host/ApiKeys/ and src/Cargonerds.Domain/ApiKeys/) participates in the same permission system rather than bypassing it. ApiKeyPermissionValueProvider (provider name "AK") only activates for api-key principals and grants a permission only if (a) some other provider — i.e. the owner's roles/grants — also grants it, and (b) the key itself carries the "AK" grant. The practical guarantee: a key can never exceed its owner's permissions. Management of keys is itself permission-gated (CargonerdsPermissions.ApiKeys.*, with ManageAll for admin scope). Full details live in API Authentication.
Gotchas¶
Common pitfalls
- Tree shape ≠ grants. The parent/child structure in a
PermissionDefinitionProviderdrives the management UI only. Actual role grants come from[GrantInRoles]+ the seeder. - Grants are seed-time only. Changing a
[GrantInRoles]attribute requires re-running the DbMigrator to take effect. - Inheritance is upward and on by default.
[GrantInRoles(lowRole)]grants to that role and every higher role. UseInherit = falseto pin (as Pricing'sQuoteRequestdoes). CheckAsyncthrows,IsGrantedAnyAsyncreturns bool. Pick the right one — hard gate vs. conditional shaping.[Authorize(x)]and[Authorize(Policy = x)]are the same thing in ABP (permission name = policy name). Both appear in the codebase; don't read meaning into the difference.HubEntityBaseServiceenforces reads viaReadPermission, not[Authorize]. A subclass that forgets to overrideReadPermissionleaves the empty-string default, which performs no read check. Confirm the override exists when adding a new entity service.- Unguarded CMS Kit group lookup in
CargonerdsPermissionDefinitionProviderwould throw at startup if the CMS Kit module were removed. RealtimeAdmin's permission value is duplicated in the frontend (per the source comment); changing the string requires a matching frontend update.- Several permissions are intentionally ungranted (e.g.
OrganizationUnits.AdvancedManagement, userImpersonation, the wholeInternalUsersblock) because the backing features/logic don't exist yet — their[GrantInRoles]attributes are commented out.
See also¶
- Application Services — service base classes and the auto-API surface.
- Service Patterns — the
HubEntityBaseServiceread/list pipeline that hostsReadPermission. - Permissions Reference — the full per-group permission catalogue with default roles.
- API Authentication — JWT bearer, OpenIddict, and the owner-capped API-key scheme.
- ABP Patterns — multi-tenancy and organization-unit data filtering that scopes rows on top of permissions.
- Configuration Reference — features and settings (including
isVisibleToClientsfrontend gating). - ABP docs: Authorization & permission management, Features, Identity.