Roles — RoleMetadata and /admin/roles CRUD
Why this matters
Section titled “Why this matters”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:
- A role had no declarative scope. Nothing stopped a tenant administrator from
granting a
Tenants.Managepermission (host-only) to one of their tenant roles, or from having the same role assignable in both host and tenant admin UIs. - 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.
The RoleMetadata aggregate
Section titled “The RoleMetadata aggregate”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}Invariants
Section titled “Invariants”Enforced by the static RoleMetadata.Create factory and a database CHECK
constraint (ck_authorization_role_metadata_side_tenant_consistency):
| Side | TenantId |
|---|---|
Host | must be null |
Both | must be null (defined by the platform, assignable in any tenant context) |
Tenant | must be non-null |
Uniqueness
Section titled “Uniqueness”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.
Events
Section titled “Events”Each state transition raises a plain IDomainEvent record that the
DomainEventDispatcherInterceptor dispatches after SaveChanges commits:
RoleCreatedEvent— factory callRoleUpdatedEvent—Rename(newName, newDescription)when anything actually changesRoleDeletedEvent—MarkAsDeleted()before removing the entity
System roles
Section titled “System roles”IdentityLocalRoleSeedContributor (registered by Granit.Identity.Local.AspNetIdentity)
idempotently seeds three platform roles on every IDataSeeder.SeedHostAsync run:
| Role | Side | Purpose |
|---|---|---|
SuperAdmin | Host | Platform administrator with cross-tenant access. |
TenantAdministrator | Both | Manages users, roles, and settings within a tenant. |
User | Both | Default 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.
/admin/roles CRUD
Section titled “/admin/roles CRUD”The endpoints live in Granit.Identity.Local.Endpoints and map via:
app.MapGranitRoles(opts =>{ opts.RolesRoutePrefix = "admin/roles"; // default opts.AllowTenantRoles = true; // default — tenant admins can CRUD tenant roles});| Verb | Route | Permission | Notes |
|---|---|---|---|
GET | /admin/roles | IdentityLocal.Roles.Read | Scoped to visible roles (matrix below). |
GET | /admin/roles/{id} | IdentityLocal.Roles.Read | 404 when not visible. |
POST | /admin/roles | IdentityLocal.Roles.Manage | Tenant admins create in own tenant only. Set AllowTenantRoles = false to disable tenant-scope creation entirely. |
PUT | /admin/roles/{id} | IdentityLocal.Roles.Manage | Side and tenant scope are immutable. |
DELETE | /admin/roles/{id} | IdentityLocal.Roles.Delete | System roles refuse deletion with 403. |
Visibility matrix
Section titled “Visibility matrix”Applied server-side via ICurrentTenant — the store never leaks a non-visible row:
| Caller context | Host roles | Both roles | Tenant-own roles | Other-tenant roles |
|---|---|---|---|---|
| Host admin | CRUD | CRUD | Read (audit) | Read (audit) |
| Tenant admin | ∅ (invisible) | Read + assignable | CRUD | ∅ (invisible) |
Non-visible roles always return 404, never 403 — existence is not disclosed.
Tenant-scope roles
Section titled “Tenant-scope roles”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).
Opt-out: host-only deployments
Section titled “Opt-out: host-only deployments”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:
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.
Orchestration across two DbContexts
Section titled “Orchestration across two DbContexts”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.
Grant-side coherence
Section titled “Grant-side coherence”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.
See also
Section titled “See also”Architecture decisions
Section titled “Architecture decisions”- 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-029 —
OrphanedRolePolicyfor client-role sync - ADR-030 — per-provider scheduled re-sync jobs
- ADR-031 — write operations on
IIdentityClientRoleManager