Open Closed

How to restrict an already-granted permission per access channel (mobile vs. direct API) per user? #10842


User avatar
0
adhamawadhi created

What we are trying to achieve

Our platform is consumed through several channels simultaneously — multiple web applications, mobile applications, and direct API integrations used by partner systems. All of them authenticate against the same auth server and share the same users, roles, and permission definitions.

We need permission granting to keep working exactly as it does today through user and role providers. On top of that, we need a purely subtractive rule: for a user who already holds a permission, that permission may be blocked on certain channels while remaining available on others. A user who does not hold the permission stays blocked everywhere — the channel dimension should never grant anything by itself.

The mapping differs per user. It is not a property of the permission, nor of the channel, but of the combination of user, permission, and channel.

Example

With two channels (Mobile, Api), two users (u1, u2), and two operations (op1, op2), all four permissions granted through roles:

| User | Operation | Mobile | Api | |---|---|---|---| | u1 | op1 | allowed | blocked | | u1 | op2 | blocked | allowed | | u2 | op1 | blocked | allowed | | u2 | op2 | allowed | allowed |

Our questions

  1. Does ABP already provide a built-in way to express this that we may have missed? We looked at ClientPermissionValueProvider, but it appears to grant permissions to a client rather than constrain permissions a user already holds, and it has no per-user dimension.

  2. If there is no built-in support, what is the recommended extension point for this? We would like to follow the approach the ABP team considers correct rather than pick one and discover later that it breaks on upgrade or bypasses part of the pipeline.

  3. Is there a supported way to represent an explicit denial in permission storage? PermissionGrant is keyed on provider name, provider key, and permission name, and we could not find a representation for "granted but blocked in this context."

  4. How should the client side be handled? AbpApplicationConfigurationAppService returns a single permission set per user, so a mobile app and an API caller receive an identical set. Is there a recommended way to make this endpoint channel-aware so the UI does not render actions that will fail at invocation time?

  5. Is this scenario considered in scope for the framework, or is it expected to live entirely in application code? A clear answer here is enough for us to commit to a direction.

Environment

  • ABP Framework version: 8.3
  • UI / Mobile: Angular + Mvc + Flutter for mobile
  • Database provider: EF Core / SQL Server
  • Tiered: Yes
Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

3 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    • Built-in support: Yes — ABP already supports an explicit deny (subtractive) decision via Permission Value Providers. A provider can return PermissionGrantResult.Prohibited, and if any provider returns Prohibited, the final result is always prohibited (even if user/role providers granted it). This matches your “never grant, only subtract” requirement.
    • Recommended extension point: Implement a custom PermissionValueProvider (or IPermissionValueProvider) and register it in AbpPermissionOptions.ValueProviders. In that provider, read:
      • the current user id from context.Principal (claim AbpClaimTypes.UserId)
      • the channel from the request context (typically a claim, header, or client id claim)
      • your own storage/table keyed by (UserId, PermissionName, Channel) and return Prohibited when there is a block rule; otherwise return Undefined so normal ABP granting continues.

    Example skeleton:

    using System;
    using System.Threading.Tasks;
    using Volo.Abp.Authorization.Permissions;
    using Volo.Abp.Security.Claims;
    
    public class ChannelBlockPermissionValueProvider : PermissionValueProvider
    {
        public const string ProviderName = "ChannelBlock";
    
        public override string Name => ProviderName;
    
        public ChannelBlockPermissionValueProvider(IPermissionStore permissionStore)
            : base(permissionStore)
        {
        }
    
        public override async Task<PermissionGrantResult> CheckAsync(PermissionValueCheckContext context)
        {
            var userIdString = context.Principal?.FindFirst(AbpClaimTypes.UserId)?.Value;
            if (!Guid.TryParse(userIdString, out var userId))
            {
                return PermissionGrantResult.Undefined;
            }
    
            // Example: determine channel from a claim/header you add to the principal
            var channel = context.Principal?.FindFirst("channel")?.Value; // "Mobile" / "Api"
            if (string.IsNullOrWhiteSpace(channel))
            {
                return PermissionGrantResult.Undefined;
            }
    
            // TODO: query your table: (userId, context.Permission.Name, channel) => IsBlocked
            var isBlocked = await /* your store */ Task.FromResult(false);
    
            return isBlocked
                ? PermissionGrantResult.Prohibited   // subtractive deny
                : PermissionGrantResult.Undefined;   // let role/user grants decide
        }
    }
    

    Register it:

    Configure<AbpPermissionOptions>(options =>
    {
        options.ValueProviders.Add<ChannelBlockPermissionValueProvider>();
    });
    
    • Explicit denial in ABP permission storage (PermissionGrant): ABP’s built-in PermissionGrant storage is designed around provider-based grant state; your scenario is a contextual deny (user+permission+channel), so the supported way is not to try to encode this into PermissionGrant, but to use a custom permission value provider and your own persistence. (ABP’s provider pipeline already supports the deny outcome via PermissionGrantResult.Prohibited.)
    • Client side (/api/abp/application-configuration):
      • By default it returns a single grantedPolicies map for the current principal.
      • To make it channel-aware, the supported approach is to ensure the same principal context (including your “channel” indicator claim/header -> transformed into claim) is present when that endpoint is called, then the permission checks performed server-side will reflect your Prohibited results.
      • If you need the returned permission set to differ by channel even for the same logged-in user, you must make the request channel-identifiable (e.g., a header like X-Channel: Mobile + middleware/claims transformation that adds channel claim to the principal before permission evaluation). Then the normal ABP permission evaluation used by application configuration will naturally produce the channel-specific result.
    • In-scope or application code? The framework provides the correct hook (permission value provider + Prohibited semantics). The channel-specific rule storage and how you detect “channel” is expected to be implemented in application/module code.

    Links (validated):

    • https://abp.io/docs/latest/framework/fundamentals/authorization/
    • https://abp.io/docs/latest/framework/fundamentals/authorization#permission-system
    • https://abp.io/docs/latest/modules/permission-management

    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    adhamawadhi created

    Follow-up: channel identification must not be client-supplied

    Thank you for the confirmation on the value provider approach — that resolves our main question.

    We want to flag one point in the answer that we believe is unsafe for our use case, and ask for guidance on the alternative.

    The concern

    The response suggests identifying the channel via a request header such as X-Channel: Mobile, transformed into a claim by middleware before permission evaluation. In our scenario this does not hold, because the attacker is the authenticated user themselves.

    The user holds a valid token and legitimately holds the permission. The only thing being enforced is which channel they may exercise it from. A request header is fully controlled by the sender, so the restriction can be removed by editing one string:

    # blocked
    curl -H "Authorization: Bearer <valid-token>" -H "X-Channel: Api" \
         -d '{"amount":50000}' https://api.example.com/api/wallet/transfer
    
    # same token, header changed — allowed
    curl -H "Authorization: Bearer <valid-token>" -H "X-Channel: Mobile" \
         -d '{"amount":50000}' https://api.example.com/api/wallet/transfer
    

    Authentication succeeds, the permission check passes, and the control is bypassed entirely. For a money transfer operation this is not an acceptable enforcement boundary — a security decision cannot rest on an unsigned value asserted by the caller.

    Our intended approach

    Derive the channel from the token itself rather than from the request. We plan to register a separate OpenIddict client per channel and resolve the channel from the client_id claim on the validated principal:

    | Client | Channel | Type | |---|---|---| | Wallet_Mobile | Mobile | public | | Wallet_Web | Web | public | | Partner_Api | Api | confidential (client secret) |

    with an unrecognised or absent client_id defaulting to the least-trusted channel rather than the most permissive one.

    We are aware this still has a residual weakness: Wallet_Mobile is a public client, so its client_id can be extracted from the app package and reused from any HTTP client to obtain a token that claims the Mobile channel. We consider this acceptable for now and plan to address it separately through device binding and platform attestation. We raise it only so the recommendation is not read as a hard security boundary by others with the same requirement.

    Questions

    1. Is reading AbpClaimTypes.ClientId from ICurrentPrincipalAccessor inside a permission value provider reliable across all execution paths — HTTP requests, background jobs, and distributed event handlers? We want to be sure the principal is populated consistently, since a null principal must not silently fall through to a permissive result.

    2. Is there a recommended ABP pattern for making a claim available to the permission pipeline that we should use instead of, or alongside, IAbpClaimsPrincipalContributor?

    3. Would you consider revising the header-based suggestion in the original answer? Anyone implementing it as written for a permission restriction would end up with a control that is trivially bypassable by the very user it is meant to constrain.


    Questions on channel modelling, binding, and administration

    Beyond the enforcement mechanism itself, we would like guidance on how the channel concept should be modelled and administered in an ABP-based system. We could not find an existing abstraction for this, and would prefer to align with the framework's intent rather than invent one.

    Defining channels

    1. Is there any existing notion in ABP of an "access channel" or request origin that we should build on, or is the concept expected to be introduced entirely by the application?

    2. Would you recommend channels be a static enum defined in code, or a configurable entity stored in the database so administrators can add a channel (for example a new partner integration or a USSD gateway) without a deployment? A static definition is simpler and safer, but it means permission definitions and channel definitions live in different places and drift apart over time.

    3. Should the channel be treated as a first-class concept alongside tenants and users, or as an implementation detail confined to the authorization layer? This affects whether channel identity becomes available for auditing, rate limiting, and transaction limits — which we expect to need — or stays isolated to permission checks.

    Binding channels to clients

    1. We plan a one-to-one mapping between an OpenIddict client and a channel. Is there a supported place to store this mapping — client properties, an OpenIddict client extension, application settings, or a dedicated table? We would rather not hardcode a switch on client_id.

    2. How should multiple clients on the same channel be handled? We expect several distinct web applications that should all resolve to the Web channel, and multiple partner integrations each with its own client but all belonging to the Api channel. Is a many-to-one client-to-channel relationship the recommended shape?

    3. Should the channel be exposed as a scope rather than derived from client_id? A scope is requested per token and could give finer control, but it is also negotiable by the client at token request time, which appears weaker for our purpose. We would appreciate your view on the trade-off.

    4. How should tokens issued through flows without a meaningful client be treated — client credentials for internal service-to-service calls, background jobs, and distributed event handlers? Our current intent is to resolve these to a dedicated internal channel that is excluded from channel restrictions entirely, but we would like to know whether that matches the framework's expectations.

    Administration

    1. The built-in permission management modal is built around a two-dimensional model (subject × permission). Is there a supported way to extend it with a third dimension, or is a separate management screen the expected path? If a separate screen is expected, is there a recommended way to reuse IPermissionAppService so administrators do not see two disconnected permission UIs?

    2. Should channel restrictions be manageable at the role level as well as the user level? Per-user rules alone will not scale for us — a tenant with thousands of users would need a rule row per user. If role-level restrictions are advisable, what precedence would you recommend between a role-level and a user-level rule?

    3. Are channel restrictions expected to be tenant-scoped? In a multi-tenant deployment, should a host administrator be able to define channel restrictions that a tenant administrator cannot override, and is there an existing ABP pattern for that kind of layered policy?

    4. Is there guidance on auditing changes to these rules? Given that they govern financial operations, we need a durable record of who changed a restriction and when. We assume ABP's entity change auditing covers this, but would like confirmation that it applies cleanly to a custom entity of this kind.

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    The permission system has no channel dimension and PermissionGrant has no grant/deny state, so the channel part is yours to model. The enforcement hook is the framework's though: a permission value provider returning PermissionGrantResult.Prohibited. On a single permission check any Prohibited beats the user and role providers no matter where the provider sits in the pipeline, and the same provider covers [Authorize], IPermissionChecker and the permission list behind /api/abp/application-configuration.

    Deriving the channel from client_id rather than a header is the right call. OpenIddict sets client_id when it builds the access token principal, so it's there for authorization_code and password tokens alike and it's already on the validated principal — no claims contributor needed. Against a scope: a client chooses which of its allowed scopes to request, while the validated token ties the request back to the client registration that obtained it.

    Read it from context.Principal, not ICurrentPrincipalAccessor. IPermissionChecker.IsGrantedAsync(ClaimsPrincipal, ...) lets a caller pass a principal that isn't the ambient one, and only context.Principal follows that.

    One thing that will bite you on 8.3: register the provider with Insert(0, ...), not Add<T>().

    Configure<AbpPermissionOptions>(options =>
    {
        options.ValueProviders.Insert(0, typeof(ChannelBlockPermissionValueProvider));
    });
    

    The batch overload of IPermissionChecker keeps the first non-Undefined result per permission on 8.3, so a provider sitting after U/R never gets to prohibit what they already granted. The single-permission path still blocks, which makes this easy to miss: the direct API call returns 403 while /api/abp/application-configuration keeps listing the permission as granted. In a tiered solution that matters — the MVC layer runs RemotePermissionChecker and takes every decision from that payload, so the block isn't enforced there at all. This changed in 9.3.7 and 10.0.1 (https://github.com/abpframework/abp/pull/24283); from those versions Prohibited wins regardless of position.

    The provider:

    public class ChannelBlockPermissionValueProvider : PermissionValueProvider
    {
        public const string ProviderName = "ChannelBlock";
    
        public override string Name => ProviderName;
    
        protected ICurrentTenant CurrentTenant { get; }
    
        public ChannelBlockPermissionValueProvider(IPermissionStore permissionStore, ICurrentTenant currentTenant)
            : base(permissionStore)
        {
            CurrentTenant = currentTenant;
        }
    
        public static string BlockKey(string channel, string subjectType, string subjectId)
        {
            return $"{channel}|{subjectType}|{subjectId}";
        }
    
        public override async Task<PermissionGrantResult> CheckAsync(PermissionValueCheckContext context)
        {
            var userId = context.Principal?.FindFirst(AbpClaimTypes.UserId)?.Value;
            if (userId == null)
            {
                // No user: client credentials, background jobs, event handlers.
                // This model only subtracts from user and role grants.
                return PermissionGrantResult.Undefined;
            }
    
            var channel = ResolveChannelOrNull(context.Principal);
            if (channel == null)
            {
                return PermissionGrantResult.Prohibited;
            }
    
            if (await IsBlockedAsync(context.Permission.Name, BlockKey(channel, "U", userId)))
            {
                return PermissionGrantResult.Prohibited;
            }
    
            foreach (var role in context.Principal.FindAll(AbpClaimTypes.Role).Select(x => x.Value))
            {
                if (await IsBlockedAsync(context.Permission.Name, BlockKey(channel, "R", role)))
                {
                    return PermissionGrantResult.Prohibited;
                }
            }
    
            return PermissionGrantResult.Undefined;
        }
    
        protected virtual async Task<bool> IsBlockedAsync(string permissionName, string key)
        {
            if (await PermissionStore.IsGrantedAsync(permissionName, ProviderName, key))
            {
                return true;
            }
    
            if (CurrentTenant.Id == null)
            {
                return false;
            }
    
            // Host rules are invisible from inside a tenant otherwise.
            using (CurrentTenant.Change(null))
            {
                return await PermissionStore.IsGrantedAsync(permissionName, ProviderName, key);
            }
        }
    
        // Override the PermissionValuesCheckContext overload the same way - that is the one
        // /api/abp/application-configuration goes through, and it is easy to patch one and
        // forget the other.
    }
    

    Precedence falls out of this: user rule, role rule, tenant row and host row are all unioned, and nothing grants anything back — a tenant admin cannot clear a host rule.

    Three things around the provider. Register it in the host that serves your APIs, not in a shared layer your auth server also loads — the auth server signs users in with its own cookie and that principal has no client_id, so every permission check running under it would hit the deny branch. If any of your permissions are declared with WithProviders(...), add ChannelBlock to that list or the check skips this provider for them. And ProviderKey is capped at 64 characters, so keep channel codes short, especially if you key on role names.

    ResolveChannelOrNull reads AbpClaimTypes.ClientId and looks it up in your own client-to-channel table. A table rather than a switch: many clients to one channel is the shape you want, and an admin can add a partner integration or a USSD gateway without a deployment. Expose the resolved channel through a small ambient service of your own rather than burying it in the provider — you'll want the same value for audit entries, rate limits and transaction limits. It doesn't belong in ICurrentTenant.

    For storage you don't need a new mechanism. PermissionGrant is keyed on provider name, provider key and permission name, and a row is only ever selected when a provider asks for that provider name — so ChannelBlock / Mobile|U|<user-id> is invisible to U, R and C. Derive a PermissionManagementProvider with the same name and you can write those rows through IPermissionManager; the entity change drops the matching PermissionStore cache entry, so a rule takes effect on the API side without a restart. One management provider covers every channel because the channel lives in the key, so a channel added at runtime needs no new registration.

    For the admin screen, map a policy first or IPermissionAppService refuses the provider outright:

    Configure<PermissionManagementOptions>(options =>
    {
        options.ManagementProviders.Add<ChannelBlockPermissionManagementProvider>();
        options.ProviderPolicies[ChannelBlockPermissionValueProvider.ProviderName] = "YourApp.ChannelBlocks.Manage";
    });
    

    After that the app service reads and writes against your provider name and key unchanged, so the built-in permission management modal renders and saves the rules. It is still one provider/key per open, so this is one view per channel driven from your own entry point rather than a real third dimension — and a checked box means "granted" there while it means "blocked" here, so relabel it before an operator sees it.

    Auditing is off for these rows by default: PermissionGrant carries no audit properties and AbpAuditingOptions.EntityHistorySelectors is empty, so add a selector for PermissionGrant and you get property-level old/new values under the audit log entry, with its operator, timestamp and correlation id. What you won't get is the business reason or approval behind a rule, so record that yourself.

    Last one, and it matters more than it looks for a tiered setup. The MVC layer caches the whole permission list under ApplicationConfiguration_{userId}_{culture} for ApplicationConfigurationDtoCacheAbsoluteExpiration, 300 seconds by default. Changing a rule flips the API side immediately, but the MVC layer keeps serving the old answer until that entry expires. Drop the cache entry for the affected user when a rule changes if you need it to take effect at once. And because the generated hosts share one KeyPrefix, two web applications on different channels pointed at the same Redis will read each other's entry — override MvcCachedApplicationConfigurationClient.CreateCacheKey() to put the channel in the key.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 16, 2026, 14:50
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.