Appearance
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
| Property | Value |
|---|---|
| Provider ID | microtec-company-branch-mapper |
| Java class | com.microtec.keycloak.mapper.CompanyBranchProtocolMapper |
| Factory class | CompanyBranchProtocolMapperFactory |
| JAR | keycloak-company-branch-mapper-{version}.jar |
| Mapper Type | ProtocolMapper (OIDC) |
| Token Target | Access token, ID token |
Claims Injected
| Claim Name | Type | Example Value | Description |
|---|---|---|---|
companyId | integer | 42 | Tenant company primary key |
companyCode | string | "ACME" | Short company identifier |
companyName | string | "Acme Trading Co." | Display name |
branchId | integer | 7 | Active branch primary key |
branchCode | string | "RUH-01" | Branch short code |
branchName | string | "Riyadh HQ" | Branch display name |
currencyCode | string | "SAR" | Company base currency |
fiscalYearId | integer | 2025 | Active 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 1Branch 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:
- Navigate to Clients → {client-id} → Client Scopes → {client-name}-dedicated
- Click Add Mapper → By Configuration
- Select Microtec Company Branch Mapper
- Configure:
| Field | Value |
|---|---|
| Name | company-branch |
| Add to ID token | On |
| Add to access token | On |
| Add to userinfo | Off |
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
| Variable | Description |
|---|---|
ADMIN_DB_CONNECTION_STRING | JDBC connection string to the Microtec admin database |
COMPANY_BRANCH_CACHE_TTL_SECONDS | Override 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
| Symptom | Likely Cause | Resolution |
|---|---|---|
companyId claim missing from token | Admin DB unreachable | Check ADMIN_DB_CONNECTION_STRING; verify DB connectivity from Keycloak container |
| Stale branch in token | Cache not expired yet | Wait up to 5 minutes, or restart Keycloak to flush cache |
| Wrong company loaded | Multiple assignments, no default set | Set is_default = 1 on correct user_company_assignments row |
| Mapper not listed in admin console | JAR not deployed | Check /opt/keycloak/providers/ for the mapper JAR |
| SQL exception in Keycloak logs | Schema mismatch | Verify admin DB schema matches expected table structure |
Related Documentation
- Keycloak Overview — SPI list and deployment
- ERP Policy Mapper — Role-to-permission mapping SPI
- JWT Claims Reference — Full claim inventory for backend services
- Multi-Tenancy Architecture — Tenant isolation patterns
- Deployment — JAR deployment process