Permission Security
By default, the ServicePlatform IdentityServer uses classical Microsoft.AspNetCore.Identity user roles for access control. Additionally, an ItemSecurity system is implemented to secure each entity using user roles, parent-entity traversing, Security Anchors, and other techniques to control read and write access.
The Permission Security system introduces another level of security to the ServicePlatform by creating function-based permissions.
Overview
The Permission Security system requires multiple components, which can be overridden using DI registrations of the respective interfaces. Here is a brief overview of the components:
Permission
This is the smallest part of the Permission Security system. A single permission defines specific access to a particular method or function. A permission is represented as a simple string with multiple segments, separated by a :: delimiter.
The permission strings are mostly generated dynamically when accessing methods. They may also include IDs of affected entities when applicable. A user is assigned permission strings (using Permission Groups) that can contain wildcards and/or numeric checks like gte (greater than or equal), lte (less than or equal), and eq (equal).
The requested permission string is matched against the user's assigned permission strings. If no match is found, access is denied.
Examples:
checkout::order::finish– default named permissioncheckout::deliveryaddress::*– permission with wildcardcheckout::deliveryaddress::{deliveryAddressId}– permission with variablescheckout::order::finish::lte500– permission with numeric value checksuser::userhandling::read– default named permissionuser::userhandling::manage– default named permission
Permission Group
The next level is the Permission Group (defined as IPermissionGroupModel), which groups fine-grained permissions into a single, higher-level permission. A Permission Group can be used to render a user interface to assign permissions to a user, and it includes localized strings for labels and parameters.
A Group contains the following fields:
| Property | Description |
|---|---|
| Name | Name of the permission group |
| Type | Type of permission group (a string to allow extensibility); base system supports: boolean, grouped-select, and group-with-parameters |
| Key | Programmatic key for this permission |
| GroupKey | Key to group multiple groups (applicable for grouped-select type groups) |
| Configuration | Detailed configuration of the Permission Group (extensible base configuration class, currently used for group-with-parameters type groups) |
Process Overview
One or multiple permission groups (with or without configurations) are assigned either directly to a user or to a Role entity (e.g., a Company Role, which is then assigned to a user).
At login time, the Permission Groups (loaded in the User Profile model accessed by the IdentityServer) are attached as separate claims labeled "bsp:permission-group." This allows any system with access to claims values to check the permission groups assigned to a user.
Permission-Check Process
- When an action or method first checks a permission string, the permission groups are resolved using the registered
IPermissionGroupResolver. - With all resolved permission strings, a
PermissionTrie(see Trie) is constructed. - The resulting
PermissionTrieis then cached usingIMemoryCachefor the current user session (or a maximum of 20 minutes) and reused for every call. - Finally,
PermissionTrie.HasPermission(string permission)is called to check if the current user has permission to access the action or method.
Permission Trie
The PermissionTrie class is used within the Permission Security system to efficiently check if a specific permission string is available to the user. It is implemented as a Trie data structure, which allows for quick lookups of permissions, especially when dealing with complex permissions with segments, wildcards, and conditional checks.
How It Works
When a user’s permissions are loaded, the PermissionTrie is constructed based on the list of permission strings assigned to the user. Each segment of a permission string (separated by ::) represents a level in the Trie. This hierarchical structure enables efficient matching of permissions, especially for permissions with multiple levels or wildcard segments.
The PermissionTrie.HasPermission(string permission) method checks if the user has a specified permission by traversing the Trie, allowing for both exact matches and pattern-based matches.
Supported Patterns
The PermissionTrie supports several types of permission patterns:
- Exact Match: Permissions like
checkout::order::finishmatch the full, exact path in the Trie. - Wildcard Match: The wildcard symbol
*in permission strings (e.g.,checkout::deliveryaddress::*) allows matching any value at the wildcard position and all subsequent levels. - Conditional Match: Conditions like
LTE500,GTE300, orEQ100are parsed and evaluated during permission checks. For example,checkout::order::finish::LTE500only matches if the numerical segment is less than or equal to500.
Benefits of Using Permission Trie
- Efficiency: The Trie structure allows fast lookups by avoiding a full linear scan of permissions.
- Pattern Flexibility: With support for wildcards and conditional checks, the Trie accommodates complex permission rules efficiently.
- Caching: The constructed Trie is cached per user session to reduce re-processing and improve performance, with a default cache duration of 20 minutes.
Composition of a permission string
To compose a permission string (either for a PermissionGroup, or for a specific permission to check), you should use the PermissionStringBuilder. There you have the possibilities to add the single segments using a simple fluent-API. Finally the method .Build() creates the final permission string to work with.
Example usage
// Read-Only permission for any UserModel EntityStructureValueModel
var entityStructurePermission = PermissionStringBuilder.Create().EntityStructureSegment<UserModel>().Wildcard().Read().Build();
// Write permission to a specific UserModel EntityStructureValueModel
var specificEntityStructurePermission = PermissionStringBuilder.Create().EntityStructureSegment<UserModel>().EntityId(entityId).Write().Build();
// Custom permission for 'custommodule' / 'customaction'
var customPermission = PermissionStringBuilder.Create().Segment("custommodule").Segment("customaction").Build();
Background Worker for Permission Group Registration
The Permission Security system includes a background worker, RegisterSecurityGroupsWorker, responsible for registering predefined permission groups when the application starts. This worker ensures that all permission groups are loaded and available for permission checking as soon as users begin interacting with the system.
How It Works
The RegisterSecurityGroupsWorker is implemented as a hosted background service that performs the following steps on startup:
-
Scoped Service Creation: The worker opens a scoped service context to access required dependencies, such as
IImpersonateUserManagerandIPermissionGroupStore. -
System Impersonation: Using
IImpersonateUserManager, the worker operates with system-level privileges, allowing it to perform registration actions without requiring user credentials. -
Permission Group Registration: The worker iterates through each permission group defined in the
StaticPermissionGroupConfiguration(loaded through application configuration or DI), invokingStorePermissionGroupto ensure each group is saved in theIPermissionGroupStore. If a group already exists, it is updated to reflect the latest configuration.
Adding Permission Groups with RegisterPermissionGroup
The RegisterPermissionGroup extension method on IServiceCollection simplifies defining and adding permission groups within the dependency injection setup. This method is commonly used in the Startup or ModuleRegistrar to register permission groups and is compatible with the RegisterSecurityGroupsWorker configuration.
Here’s how permission groups are added using RegisterPermissionGroup:
services.RegisterPermissionGroup(
"administrator",
LocalizedStringHelper.Create("Is Administrator", LanguagesConstants.AllCultures),
options => options.AddPermission(o => o.Segment("*"))
);
Each permission group is defined with the following key elements:
- Key: A unique identifier for the permission group, such as
"administrator". - Localized Name: A name to be displayed in the UI, defined as a
LocalizedStringModel. - Permissions: Fine-grained permissions are added using the
AddPermissionmethod, which accepts permissions with segments, wildcards, or parameters.
The options parameter in RegisterPermissionGroup allows for configuring additional permissions or customization for the permission group. These registered groups are then stored by the background worker on startup.
Basic Setup
Here is a basic example with the default implementations:
Add Basic Permission Security System
In the ModuleRegistrar, add this IServiceCollection extension call:
services.AddPermissionSecurity(o => o
.WithEntityStructurePermissionGroupProvider(attributeSystemBuilder)
);
This initializes the Permission System with the default Entity Structure Permission Group Provider (modifiable with WithPermissionGroupProvider<TProvider>()).
Define/Register Permission Groups
The desired Permission Groups can then be defined/registered using the IServiceCollection extension method RegisterPermissionGroup(..):
services.RegisterPermissionGroup(
"administrator",
LocalizedStringHelper.Create("Is Administrator", LanguagesConstants.AllCultures),
options => options.AddPermission(o => o.Segment("*"))
);
Assign Permission Groups to Users
When the given permission groups are registered and available in the system, they must be added to any user-related data structure (either the user itself or a related structure, e.g., a CompanyRole that is attached to a user).
As this task is project-specific, it needs to be implemented individually. Finally, the abstract Buzzle.Module.Abstractions.Business.Models.BaseProfileModel must populate the property string[] PermissionGroups so that the selected permission groups can be added to the user claims during login.
To maintain consistency in the assigned PermissionGroups, the methods AddPermissionGroup and RemovePermissionGroup of the manager Buzzle.ServicePlatform.Infrastructure.Features.PermissionSecurity.Managers.IPermissionManager can be used.
Check Permissions
After the permission groups are created and the user claims contain the permission groups, permissions can be checked using both automatic and manual techniques:
-
EntityStructure ItemSecurity: When adding
.EntityStructureSegment<TModel>()segments while defining the permissions of a permission group, these permissions are automatically checked in thePermissionBasedItemSecurityResolverduring Entity ItemSecurity checks. -
Endpoint Configuration: When defining endpoints, the required permission can be specified in the security configuration (
.WithSecurityConfiguration(...)) using the method.WithSecurityPermissionCheck(...). -
Custom Controller Attribute: To check permission strings for a controller or a specific action, you can use the
PermissionAuthorizationAttributeeither on the controller or the action.
Cross-Module Permission Groups
When a user's permissions need to span multiple modules, the Permission Security system allows for consistent handling by defining cross-module permission groups. While the system is inherently module-based, you can achieve cross-module functionality by defining the same permission group key across required modules. Each module specifies only its relevant, module-specific permissions.
Permission Groups are stored in user claims and automatically merged when passed to each module. This ensures that a user's permissions are validated against the module-defined permissions seamlessly.
Key Rule: Each module must use the same PermissionGroup key to ensure consistency across modules.
Example Configuration
The following table illustrates how to configure a cross-module permission group for a sample use case:
| Property | Values |
|---|---|
| PermissionGroup key (same for all modules) | deliveryconfiguration-read |
| Business-Module permissions | company::deliveryaddress::*::read |
| Order-Module permissions | order::configuration::deliveryconfiguration::read |
In this example:
- If a user has the
deliveryconfiguration-readpermission group set, they automatically gain:company::deliveryaddress::*::readpermissions in the Business Module.order::configuration::deliveryconfiguration::readpermissions in the Order Module.
This functionality is achieved as the PermissionTrie in each module dynamically loads the module-specific groups and their respective configurations.