Skip to content

Company & Branch Protocol Mapper SPI

The keycloak-company-branch-mapper is a custom Protocol Mapper SPI that enriches the JWT access token with the authenticated user's active company and branch context. These claims are read by backend services to scope database queries to the correct tenant partition without an additional round-trip.


Purpose

Microtec ERP is a multi-tenant system where each user belongs to a company (tenant) and operates within a specific branch of that company. Backend services need this context on every request. Instead of looking it up from the database on each call, the claims are embedded in the JWT at token issue time.


SPI Metadata

PropertyValue
Provider IDmicrotec-company-branch-mapper
Java classcom.microtec.keycloak.mapper.CompanyBranchProtocolMapper
Factory classCompanyBranchProtocolMapperFactory
JARkeycloak-company-branch-mapper-{version}.jar
Mapper TypeProtocolMapper (OIDC)
Token TargetAccess token, ID token

Claims Injected

Claim NameTypeExample ValueDescription
companyIdinteger42Tenant company primary key
companyCodestring"ACME"Short company identifier
companyNamestring"Acme Trading Co."Display name
branchIdinteger7Active branch primary key
branchCodestring"RUH-01"Branch short code
branchNamestring"Riyadh HQ"Branch display name
currencyCodestring"SAR"Company base currency
fiscalYearIdinteger2025Active fiscal year

Data Source

Claims are fetched from the Admin database — the cross-tenant database that holds company, branch, and user-assignment records. This is NOT the tenant-specific database.

java
// CompanyBranchProtocolMapper.java — data fetch
private CompanyBranchRecord fetchRecord(String userId, KeycloakSession session) {
    String adminConnStr = System.getenv("ADMIN_DB_CONNECTION_STRING");

    try (Connection conn = DriverManager.getConnection(adminConnStr);
         PreparedStatement ps = conn.prepareStatement("""
             SELECT
                 c.id          AS company_id,
                 c.code        AS company_code,
                 c.name        AS company_name,
                 b.id          AS branch_id,
                 b.code        AS branch_code,
                 b.name        AS branch_name,
                 c.currency_code,
                 fy.id         AS fiscal_year_id
             FROM user_company_assignments uca
             JOIN companies c  ON c.id = uca.company_id
             JOIN branches  b  ON b.id = uca.default_branch_id
             JOIN fiscal_years fy ON fy.company_id = c.id AND fy.is_active = 1
             WHERE uca.user_external_id = ?
               AND uca.is_active = 1
             """)) {
        ps.setString(1, userId);
        ResultSet rs = ps.executeQuery();
        if (rs.next()) {
            return CompanyBranchRecord.fromResultSet(rs);
        }
    }
    return null;
}

Caching Strategy

The mapper implements a short-lived in-process cache (Caffeine) to avoid hitting the admin database on every token issuance within a session:

java
private static final Cache<String, CompanyBranchRecord> CACHE =
    Caffeine.newBuilder()
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .maximumSize(10_000)
        .build();

@Override
public AccessToken transformAccessToken(AccessToken token, ...) {
    String userId = userSession.getUser().getId();
    CompanyBranchRecord record = CACHE.get(userId, this::fetchRecord);

    if (record != null) {
        token.getOtherClaims().put("companyId",    record.companyId());
        token.getOtherClaims().put("companyCode",  record.companyCode());
        token.getOtherClaims().put("companyName",  record.companyName());
        token.getOtherClaims().put("branchId",     record.branchId());
        token.getOtherClaims().put("branchCode",   record.branchCode());
        token.getOtherClaims().put("branchName",   record.branchName());
        token.getOtherClaims().put("currencyCode", record.currencyCode());
        token.getOtherClaims().put("fiscalYearId", record.fiscalYearId());
    }
    return token;
}

Cache TTL vs Token Lifetime

The 5-minute cache TTL is shorter than the 30-minute access token lifetime. If a user's branch is changed by an admin, the old claims remain valid in already-issued tokens until expiry. Services that need real-time branch data should re-validate on branch-sensitive operations.


Multi-Company Support

Some users (typically system-admin role) have access to multiple companies. In this case the mapper selects the company/branch based on the user's default assignment:

sql
-- Priority: explicit default flag > lowest company_id
SELECT ... FROM user_company_assignments
WHERE user_external_id = ?
  AND is_active = 1
ORDER BY is_default DESC, company_id ASC
LIMIT 1

Branch Switching

Branch switching within a session is handled by the frontend via a token refresh call. The user selects a new branch in the ERP UI → Angular triggers keycloak.updateToken() → Keycloak issues a new token → mapper fetches the updated branch record. The frontend stores the selected branchId in a user attribute before the refresh.


Mapper Configuration (Admin Console)

The mapper is assigned to each client in the microtec realm via the seeding SPI. Manual configuration:

  1. Navigate to Clients → {client-id} → Client Scopes → {client-name}-dedicated
  2. Click Add Mapper → By Configuration
  3. Select Microtec Company Branch Mapper
  4. Configure:
FieldValue
Namecompany-branch
Add to ID tokenOn
Add to access tokenOn
Add to userinfoOff

Backend Claim Extraction

In .NET backend services, the claims are extracted via the ITenantContextManager service:

csharp
// TenantContextManager.cs
public class TenantContextManager : ITenantContextManager
{
    public int CompanyId => int.Parse(
        _httpContextAccessor.HttpContext!.User
            .FindFirstValue("companyId") ?? "0");

    public int BranchId => int.Parse(
        _httpContextAccessor.HttpContext!.User
            .FindFirstValue("branchId") ?? "0");

    public string CurrencyCode =>
        _httpContextAccessor.HttpContext!.User
            .FindFirstValue("currencyCode") ?? "SAR";
}

Services inject ITenantContextManager via DI and call BranchId / CompanyId without any database lookup.


Environment Variables Required

VariableDescription
ADMIN_DB_CONNECTION_STRINGJDBC connection string to the Microtec admin database
COMPANY_BRANCH_CACHE_TTL_SECONDSOverride default 300-second cache TTL (optional)

The connection string is sourced from Key Vault secret CompanyBranch--AdminDb--ConnectionString and injected into the Keycloak container at startup.


Troubleshooting

SymptomLikely CauseResolution
companyId claim missing from tokenAdmin DB unreachableCheck ADMIN_DB_CONNECTION_STRING; verify DB connectivity from Keycloak container
Stale branch in tokenCache not expired yetWait up to 5 minutes, or restart Keycloak to flush cache
Wrong company loadedMultiple assignments, no default setSet is_default = 1 on correct user_company_assignments row
Mapper not listed in admin consoleJAR not deployedCheck /opt/keycloak/providers/ for the mapper JAR
SQL exception in Keycloak logsSchema mismatchVerify admin DB schema matches expected table structure

Internal Documentation — Microtec Platform Team