Skip to content

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_VIEW

SPI Metadata

PropertyValue
Provider IDmicrotec-erp-policy-mapper
Java classcom.microtec.keycloak.mapper.ErpPolicyProtocolMapper
Factory classErpPolicyProtocolMapperFactory
JARkeycloak-erp-policy-mapper-{version}.jar
Mapper TypeProtocolMapper (OIDC)
Token TargetAccess 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 PrefixExamples
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:

  1. An INSERT into erp_role_permissions
  2. 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 registry

Mapper Configuration (Admin Console)

Assign the mapper to each client's dedicated scope in the microtec realm:

  1. Clients → {client-id} → Client Scopes → {client-name}-dedicated
  2. Add Mapper → By Configuration → Microtec ERP Policy Mapper
FieldValue
Nameerp-permissions
Add to ID tokenOff (token size concern)
Add to access tokenOn
Add to userinfoOff

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

  1. Add the permission code to the erp_role_permissions table:
    sql
    INSERT INTO erp_role_permissions (keycloak_role, permission_code, module)
    VALUES ('erp-admin', 'REPORT_ADVANCED_EXPORT', 'REPORTING');
  2. Update the backend policy registration to include the new code.
  3. Add the [Authorize(Policy = "REPORT_ADVANCED_EXPORT")] attribute to the controller.
  4. Wait up to 10 minutes for the Keycloak cache to refresh, or restart Keycloak.

Troubleshooting

SymptomLikely CauseResolution
permissions claim missingMapper not assigned to client scopeAssign via Admin Console or seeding SPI config
Permission present in token but 403 returnedBackend policy not registeredAdd policy registration in AddAuthorizationPolicies()
Stale permissions after role changeCache not refreshedRestart Keycloak or wait 10 minutes
Very large token sizeToo many permissions for user rolesReview role assignments; consider splitting into module-scoped tokens

Internal Documentation — Microtec Platform Team