Skip to content

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:

  1. Creates every role in RoleConsts.Hierarchy if missing, and marks DefaultCustomer as the default role.
  2. Reflects over HubPermissions, CargonerdsPermissions, and PricingPermissions (the AttributedPermissionTypes array), recursing into nested classes to find every string constant carrying [GrantInRoles].
  3. For each, expands the role list upward through the hierarchy when Inherit == true, then calls IPermissionManager.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) voidthrows 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 PermissionDefinitionProvider drives 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. Use Inherit = false to pin (as Pricing's QuoteRequest does).
  • CheckAsync throws, IsGrantedAnyAsync returns 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.
  • HubEntityBaseService enforces reads via ReadPermission, not [Authorize]. A subclass that forgets to override ReadPermission leaves 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 CargonerdsPermissionDefinitionProvider would 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, user Impersonation, the whole InternalUsers block) because the backing features/logic don't exist yet — their [GrantInRoles] attributes are commented out.

See also