Appearance
RBAC and Policies
Microtec uses a two-layer role-based access control model: Keycloak roles define what a user is, and the erp-policy-mapper SPI translates those roles into fine-grained ERP permission codes that control what a user can do.
Architecture
The permission codes are injected into the JWT permissions claim by the erp-policy-mapper Protocol Mapper SPI. Backend services read the claim and check it against [Authorize(Policy = "...")] attributes. Keycloak roles themselves are not evaluated by backend services — only the derived permission codes.
Role Hierarchy
microtec Realm Roles
Roles are hierarchical. Higher roles inherit all permissions of lower roles:
SystemAdmin
└── TenantAdmin
└── ModuleAdmin
├── AccountingManager
│ └── Accountant
├── HRManager
│ └── HROfficer
├── InventoryManager
│ └── InventoryOfficer
├── SalesManager
│ └── SalesOfficer
└── Viewer (read-only across all modules)businessowner Realm Roles
PlatformAdmin (Microtec staff)
└── TenantOwner
└── TenantManager
└── TenantViewerPermission Code Conventions
Permission codes follow the pattern: {Module}.{Action}
| Module | Actions |
|---|---|
Accounting | Read, Write, Approve, Void, Export |
HR | Read, Write, Approve, Payroll |
Inventory | Read, Write, Adjust, Transfer |
Sales | Read, Write, Approve, Discount |
Purchase | Read, Write, Approve |
Reporting | Read, Export |
Admin | TenantConfig, UserManagement, AuditLog |
Full permission code examples: Accounting.Approve, HR.Payroll, Admin.UserManagement.
Role-to-Permission Mapping
The erp-policy-mapper SPI reads the role-to-permission mapping from a configuration file loaded at Keycloak startup:
json
// KeycloakProviders/src/Config/role-permission-mapping.json
{
"rolePermissions": {
"Accountant": [
"Accounting.Read",
"Accounting.Write"
],
"AccountingManager": [
"Accounting.Read",
"Accounting.Write",
"Accounting.Approve",
"Accounting.Void",
"Accounting.Export",
"Reporting.Read",
"Reporting.Export"
],
"HRManager": [
"HR.Read",
"HR.Write",
"HR.Approve",
"HR.Payroll",
"Reporting.Read"
],
"TenantAdmin": [
"Admin.TenantConfig",
"Admin.UserManagement",
"Admin.AuditLog"
],
"SystemAdmin": [
"*"
]
}
}"*" is a wildcard granting all permissions (SystemAdmin only).
Backend Policy Registration
ASP.NET Core authorization policies are registered in Microtec.Web.Core:
csharp
// Microtec.Web.Core / Extensions/AuthorizationExtensions.cs
public static IServiceCollection AddMicrotecAuthorization(
this IServiceCollection services)
{
services.AddAuthorization(options =>
{
// Convenience policies for common permission checks
options.AddPolicy("CanReadAccounting",
p => p.RequireClaim("permissions", "Accounting.Read"));
options.AddPolicy("CanApproveAccounting",
p => p.RequireClaim("permissions", "Accounting.Approve"));
options.AddPolicy("CanManageUsers",
p => p.RequireClaim("permissions", "Admin.UserManagement"));
// Generic permission policy builder
options.AddPolicy("HasPermission", p =>
p.Requirements.Add(new PermissionRequirement()));
});
services.AddSingleton<IAuthorizationHandler, PermissionRequirementHandler>();
return services;
}Controller Usage
csharp
[ApiController]
[Route("api/v1/journal")]
[Authorize]
public class JournalController : ControllerBase
{
[HttpGet]
[Authorize(Policy = "CanReadAccounting")]
public async Task<IActionResult> GetAll(...) { ... }
[HttpPost("approve/{id}")]
[Authorize(Policy = "CanApproveAccounting")]
public async Task<IActionResult> Approve(int id, ...) { ... }
}For dynamic permission checks where the required permission depends on the operation:
csharp
// Using the generic HasPermission policy with resource-based auth
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var authResult = await _authorizationService
.AuthorizeAsync(User, "Accounting.Void");
if (!authResult.Succeeded)
return Forbid();
// ...
}XApiKey for Service-to-Service Calls
Internal service-to-service calls (via Microtec.PublicApi.* HTTP clients) bypass Keycloak authentication and use the shared XApiKey header instead:
XApiKey: 3bb564df-0f24-4ea6-82c1-d99f368cac8aThe Gateway validates the XApiKey for internal routes only. External requests reaching the Gateway with an XApiKey header but no valid JWT are rejected with 401 Unauthorized. See Inter-Service Communication for details.
XApiKey Is a Shared Secret
The XApiKey value is the same across all services within an environment but differs between environments. It is stored in Key Vault (XApiKey secret) and must be rotated via the Key Vault rotation procedure if compromised. Services read it at startup and cache it in memory — a restart is required after rotation.
Tenant Data Isolation
Permissions alone do not prevent cross-tenant data access. Tenant isolation is enforced at the data layer by ITenantContextManager, which ensures EF Core queries always include a WHERE TenantId = @tenantId filter:
csharp
// Applied globally via EF Core query filter
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Invoice>()
.HasQueryFilter(i => i.TenantId == _tenantProvider.TenantId);
}A user with Accounting.Read can only read invoices belonging to their own tenant, even if they somehow obtained a token with another tenant's tenant_id claim — the DB query filter provides a second enforcement layer.