Appearance
ERP Policy Mapper SPI
The keycloak-erp-policy-mapper is a custom Protocol Mapper SPI that translates Keycloak realm roles and client roles into granular ERP permission codes embedded directly in the JWT access token. Backend services evaluate these permission codes for fine-grained authorization without consulting a separate policy service.
Why a Custom Policy Mapper?
Keycloak's built-in role mapper injects raw role names (e.g., erp-admin, accounting-user). These role names are too coarse for ERP authorization, where a user may need permission to view invoices but not approve them. The ERP Policy Mapper bridges this gap by expanding roles into specific permission codes:
erp-admin
→ INVOICE_VIEW, INVOICE_CREATE, INVOICE_APPROVE,
VOUCHER_VIEW, VOUCHER_POST, REPORT_EXPORT, ...
accounting-user
→ INVOICE_VIEW, INVOICE_CREATE, VOUCHER_VIEWSPI Metadata
| Property | Value |
|---|---|
| Provider ID | microtec-erp-policy-mapper |
| Java class | com.microtec.keycloak.mapper.ErpPolicyProtocolMapper |
| Factory class | ErpPolicyProtocolMapperFactory |
| JAR | keycloak-erp-policy-mapper-{version}.jar |
| Mapper Type | ProtocolMapper (OIDC) |
| Token Target | Access token only (not ID token — too large) |
Permission Claim Structure
The mapper adds a single permissions array claim to the access token:
json
{
"sub": "user-uuid",
"companyId": 42,
"permissions": [
"INVOICE_VIEW",
"INVOICE_CREATE",
"VOUCHER_VIEW",
"JOURNAL_VIEW",
"REPORT_EXPORT"
]
}Permission codes follow the pattern {MODULE}_{ACTION}:
| Module Prefix | Examples |
|---|---|
INVOICE_ | INVOICE_VIEW, INVOICE_CREATE, INVOICE_APPROVE, INVOICE_DELETE |
VOUCHER_ | VOUCHER_VIEW, VOUCHER_POST, VOUCHER_REVERSE |
JOURNAL_ | JOURNAL_VIEW, JOURNAL_MANUAL_ENTRY |
REPORT_ | REPORT_VIEW, REPORT_EXPORT, REPORT_SCHEDULE |
USER_ | USER_VIEW, USER_CREATE, USER_ASSIGN_ROLE |
TENANT_ | TENANT_SETTINGS, TENANT_FISCAL_YEAR |
HR_ | HR_EMPLOYEE_VIEW, HR_PAYROLL_RUN, HR_LEAVE_APPROVE |
INVENTORY_ | INVENTORY_VIEW, INVENTORY_ADJUST, INVENTORY_TRANSFER |
Role-to-Permission Mapping
The mapping table is stored in the Admin database (erp_role_permissions table), making it configurable without redeploying the SPI JAR:
sql
-- erp_role_permissions schema
CREATE TABLE erp_role_permissions (
id INT PRIMARY KEY IDENTITY,
keycloak_role NVARCHAR(100) NOT NULL, -- e.g. 'erp-admin'
permission_code NVARCHAR(100) NOT NULL, -- e.g. 'INVOICE_APPROVE'
module NVARCHAR(50), -- e.g. 'ACCOUNTING'
is_active BIT DEFAULT 1
);The mapper loads the full mapping table at startup and refreshes it every 10 minutes (configurable). This means adding a new permission requires:
- An
INSERTintoerp_role_permissions - Waiting up to 10 minutes for Keycloak to refresh (or restart Keycloak)
Implementation
java
@Override
public AccessToken transformAccessToken(
AccessToken token,
ProtocolMapperModel mappingModel,
KeycloakSession session,
UserSessionModel userSession,
ClientSessionContext clientSessionCtx) {
UserModel user = userSession.getUser();
RealmModel realm = session.getContext().getRealm();
// Collect all role names the user holds
Set<String> userRoles = user.getRealmRoleMappingsStream()
.map(RoleModel::getName)
.collect(Collectors.toSet());
// Also collect client-specific roles
realm.getClientsStream().forEach(client ->
user.getClientRoleMappingsStream(client)
.map(RoleModel::getName)
.forEach(userRoles::add)
);
// Expand roles → permission codes
List<String> permissions = userRoles.stream()
.flatMap(role -> ROLE_PERMISSION_MAP
.getOrDefault(role, Collections.emptySet()).stream())
.distinct()
.sorted()
.collect(Collectors.toList());
token.getOtherClaims().put("permissions", permissions);
return token;
}Caching
Permission mapping is cached in-process to avoid querying the admin database on every token issuance:
java
private static volatile Map<String, Set<String>> ROLE_PERMISSION_MAP = new HashMap<>();
private static volatile long LAST_REFRESH_EPOCH = 0L;
private static final long REFRESH_INTERVAL_MS = 10 * 60 * 1000L; // 10 minutes
private synchronized void refreshIfNeeded() {
if (System.currentTimeMillis() - LAST_REFRESH_EPOCH < REFRESH_INTERVAL_MS) {
return;
}
ROLE_PERMISSION_MAP = loadMappingFromDb();
LAST_REFRESH_EPOCH = System.currentTimeMillis();
}Cache Delay
Changes to the erp_role_permissions table take up to 10 minutes to reflect in newly issued tokens. For immediate effect in production (e.g., emergency permission revoke), restart the Keycloak container.
Backend Authorization Check
The .NET backend evaluates the permissions claim via a custom IAuthorizationHandler:
csharp
// ErpPermissionRequirement.cs
public class ErpPermissionRequirement : IAuthorizationRequirement
{
public string PermissionCode { get; }
public ErpPermissionRequirement(string code) => PermissionCode = code;
}
// ErpPermissionHandler.cs
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ErpPermissionRequirement requirement)
{
var permissions = context.User
.FindAll("permissions")
.Select(c => c.Value)
.ToHashSet();
if (permissions.Contains(requirement.PermissionCode))
context.Succeed(requirement);
return Task.CompletedTask;
}Usage on controller actions:
csharp
[Authorize(Policy = "INVOICE_APPROVE")]
[HttpPost("{id}/approve")]
public async Task<IActionResult> ApproveInvoice(int id) { ... }Authorization policies are registered at startup:
csharp
// Program.cs
builder.Services.AddAuthorizationPolicies(); // extension method
// Registers one policy per permission code from the ERP permission registryMapper Configuration (Admin Console)
Assign the mapper to each client's dedicated scope in the microtec realm:
- Clients → {client-id} → Client Scopes → {client-name}-dedicated
- Add Mapper → By Configuration → Microtec ERP Policy Mapper
| Field | Value |
|---|---|
| Name | erp-permissions |
| Add to ID token | Off (token size concern) |
| Add to access token | On |
| Add to userinfo | Off |
Not on ID Token
The permissions array can contain 50+ entries. Including it in the ID token would bloat the cookie/storage payload. The access token is only used server-side and is acceptable at larger sizes.
Viewing Permissions for a User
To inspect the resolved permissions for a specific user:
bash
# Get an access token and decode the payload
TOKEN=$(curl -s -X POST \
"https://keycloak.{domain}/realms/microtec/protocol/openid-connect/token" \
-d "grant_type=password&client_id=apps-accounting" \
-d "username={user}&password={pass}" | jq -r '.access_token')
# Decode and view permissions claim
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq '.permissions'Adding a New Permission
- Add the permission code to the
erp_role_permissionstable:sqlINSERT INTO erp_role_permissions (keycloak_role, permission_code, module) VALUES ('erp-admin', 'REPORT_ADVANCED_EXPORT', 'REPORTING'); - Update the backend policy registration to include the new code.
- Add the
[Authorize(Policy = "REPORT_ADVANCED_EXPORT")]attribute to the controller. - Wait up to 10 minutes for the Keycloak cache to refresh, or restart Keycloak.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
permissions claim missing | Mapper not assigned to client scope | Assign via Admin Console or seeding SPI config |
| Permission present in token but 403 returned | Backend policy not registered | Add policy registration in AddAuthorizationPolicies() |
| Stale permissions after role change | Cache not refreshed | Restart Keycloak or wait 10 minutes |
| Very large token size | Too many permissions for user roles | Review role assignments; consider splitting into module-scoped tokens |
Related Documentation
- Keycloak Overview — SPI list and deployment
- Company Branch Mapper — Companion protocol mapper SPI
- JWT Claims Reference — Full token claim inventory
- Dual Token Design — Access vs ID token usage pattern
- Deployment — JAR deployment and environment variables