Skip to content

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
              └── TenantViewer

Permission Code Conventions

Permission codes follow the pattern: {Module}.{Action}

ModuleActions
AccountingRead, Write, Approve, Void, Export
HRRead, Write, Approve, Payroll
InventoryRead, Write, Adjust, Transfer
SalesRead, Write, Approve, Discount
PurchaseRead, Write, Approve
ReportingRead, Export
AdminTenantConfig, 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-d99f368cac8a

The 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.

Internal Documentation — Microtec Platform Team