Skip to content

Roles — RoleMetadata and /admin/roles CRUD

Granit ships a dynamic RBAC system where permissions are declared statically by modules and granted to roles at runtime. Two things were missing before this feature:

  1. A role had no declarative scope. Nothing stopped a tenant administrator from granting a Tenants.Manage permission (host-only) to one of their tenant roles, or from having the same role assignable in both host and tenant admin UIs.
  2. There was no HTTP surface to create, rename, or delete roles. Platform operators had to hand-roll role management endpoints or reach into the database.

RoleMetadata and the /admin/roles CRUD close both gaps with the same MultiTenancySides vocabulary already used by PermissionDefinition.

Every Granit-managed role now has a companion RoleMetadata row that carries its declarative scope:

public sealed class RoleMetadata : AuditedAggregateRoot, IMultiTenant
{
public string Name { get; private set; } // "Manager"
public Guid? TenantId { get; private set; } // required when Side = Tenant
public string? ClientId { get; private set; } // realm / client role distinction (reserved)
public MultiTenancySides MultiTenancySides { get; private set; } // Host / Tenant / Both
public string? Description { get; private set; }
public bool IsSystem { get; private set; } // true for seeded roles
}

Enforced by the static RoleMetadata.Create factory and a database CHECK constraint (ck_authorization_role_metadata_side_tenant_consistency):

SideTenantId
Hostmust be null
Bothmust be null (defined by the platform, assignable in any tenant context)
Tenantmust be non-null

The table carries a unique composite index on (Name, TenantId, ClientId) with PostgreSQL NULLS NOT DISTINCT semantics (via the Npgsql:IndexNullsDistinct annotation). That means two host-level rows with TenantId = null and ClientId = null cannot share the same name — silent duplicates are impossible.

Each state transition raises a plain IDomainEvent record that the DomainEventDispatcherInterceptor dispatches after SaveChanges commits:

  • RoleCreatedEvent — factory call
  • RoleUpdatedEventRename(newName, newDescription) when anything actually changes
  • RoleDeletedEventMarkAsDeleted() before removing the entity

IdentityLocalRoleSeedContributor (registered by Granit.Identity.Local.AspNetIdentity) idempotently seeds three platform roles on every IDataSeeder.SeedHostAsync run:

RoleSidePurpose
SuperAdminHostPlatform administrator with cross-tenant access.
TenantAdministratorBothManages users, roles, and settings within a tenant.
UserBothDefault role assigned to every authenticated user.

All three are flagged IsSystem = true, which protects them from being renamed or deleted through the CRUD endpoints. The seeder includes a repair path that removes orphan GranitRole rows (Identity side with no matching RoleMetadata) before re-seeding, so partial failures of a previous seed self-heal.

The endpoints live in Granit.Identity.Local.Endpoints and map via:

Program.cs
app.MapGranitRoles(opts =>
{
opts.RolesRoutePrefix = "admin/roles"; // default
opts.AllowTenantRoles = true; // default — tenant admins can CRUD tenant roles
});
VerbRoutePermissionNotes
GET/admin/rolesIdentityLocal.Roles.ReadScoped to visible roles (matrix below).
GET/admin/roles/{id}IdentityLocal.Roles.Read404 when not visible.
POST/admin/rolesIdentityLocal.Roles.ManageTenant admins create in own tenant only. Set AllowTenantRoles = false to disable tenant-scope creation entirely.
PUT/admin/roles/{id}IdentityLocal.Roles.ManageSide and tenant scope are immutable.
DELETE/admin/roles/{id}IdentityLocal.Roles.DeleteSystem roles refuse deletion with 403.

Applied server-side via ICurrentTenant — the store never leaks a non-visible row:

Caller contextHost rolesBoth rolesTenant-own rolesOther-tenant roles
Host adminCRUDCRUDRead (audit)Read (audit)
Tenant admin∅ (invisible)Read + assignableCRUD∅ (invisible)

Non-visible roles always return 404, never 403 — existence is not disclosed.

Tenant admins CRUD tenant-scope roles in their own tenant by default (AllowTenantRoles = true). Tenant-scope roles would otherwise collide on ASP.NET Core Identity’s process-wide unique index on AspNetRoles.NormalizedName when two tenants create a role with the same display name. The solution is TenantAwareRoleLookupNormalizer, which prefixes the normalized name with T_{tenantId}_ whenever a tenant context is active — it’s wired automatically by Granit.Identity.Local.AspNetIdentity (no action required).

For hosts that don’t want to expose tenant-scope role creation at all (e.g. a single-tenant product that keeps permission scoping at the Host level only), opt out:

Program.cs
app.MapGranitRoles(opts =>
{
opts.AllowTenantRoles = false; // POST / with Side = Tenant now returns 403
});

When AllowTenantRoles = false the CRUD endpoints refuse Side = Tenant create requests with a 403 problem detail; Host and Both roles remain fully available.

GranitRole rows live in the Identity DbContext (e.g. OpenIddictDbContext) and RoleMetadata rows live in the host DbContext that implements IPermissionGrantDbContext. IGranitRoleOrchestrator keeps the two writes in lockstep with an atomic shared-connection transaction by default (PR #1097 / #1110), and falls back to a compensating-write strategy only when the deployment cannot expose a shared connection:

sequenceDiagram
    participant C as Caller (endpoint)
    participant O as GranitRoleOrchestrator
    participant I as Identity DbContext
    participant H as Host DbContext

    C->>O: CreateAsync(cmd)
    O->>I: OpenConnectionAsync + BeginTransactionAsync
    O->>H: UseTransactionAsync(sharedTx)
    O->>I: RoleManager.CreateAsync(new GranitRole)
    O->>H: IRoleMetadataStore.AddAsync(RoleMetadata.Create(…))
    alt either write fails
        I-->>O: throw / rollback
        O-->>C: rethrow (no half-written state)
    else both succeed
        O->>I: CommitAsync
        O-->>C: RoleMetadata
    end

The atomic path requires both DbContexts to target the same physical database and to share an EF Core connection. When that contract holds — the default deployment shape for Granit.Identity.Local.AspNetIdentity — there is no half-written state to repair.

When a deployment uses physically separate databases for identity and host data, IGranitRoleOrchestrator falls back to a compensating-write strategy: create the GranitRole first, then persist RoleMetadata; if the metadata write fails, delete the freshly created GranitRole. The fallback trades cross-DbContext atomicity for portability and is documented for completeness — the atomic transaction is the production default.

Once a role has declared metadata, the built-in MultiTenancySidePermissionGrantValidator enforces the full grant-vs-role matrix on every permission grant where ProviderName == "R". See Host/Tenant & multi-provider — Role-side scoping for the decision table and the role_side_forbidden / role_tenant_mismatch rejection codes.

  • ADR-023 — tenant-aware role lookup with IGranitRoleLookup
  • ADR-024 — shared-connection transaction for the role orchestrator
  • ADR-025 — Keycloak client-role distinction and boot-time sync
  • ADR-026 — Entra ID App Role sync via Graph API
  • ADR-027 — Cognito app-client group sync
  • ADR-029OrphanedRolePolicy for client-role sync
  • ADR-030 — per-provider scheduled re-sync jobs
  • ADR-031 — write operations on IIdentityClientRoleManager