This guide follows one case: give Alice a support-manager role. Command actions now match the permission catalog, and assignment and revocation invalidate the target subject’s permission and menu caches. The guide treats those behaviors as regression contracts.
1. Intended result
Roles are not embedded in UserAggregate and do not hold Permission CLR objects. Authorization owns stable-key relations; subject projection later builds effective roles and permissions on CurrentUser.
2. Preflight: prove catalog closure
RBAC derives a requirement mechanically:
{module}.{resourceType}.{action.ToString().ToLowerInvariant()}Current write commands are isomorphic with the catalog:
| Use case | Action | Derived and cataloged requirement |
|---|---|---|
| Grant role permission | Grant | authorization.role-permission.grant |
| Revoke role permission | Revoke | authorization.role-permission.revoke |
| Assign user role | Assign | authorization.user-role.assign |
| Revoke user role | Revoke | authorization.user-role.revoke |
# Command actions and catalog entries must remain isomorphic.rg -n "AuthorizationAction\.(Assign|Grant|Revoke)" \ src/Platform/Authorization/BitzOrcas.Platform.Authorization.Application/Commands -g '*.cs'
# Do not use a broad ABAC Allow to conceal a missing catalog code.rg -n "RolePermission(Grant|Revoke)|UserRoleRevoke" \ src/Platform/Authorization/BitzOrcas.Platform.Authorization.Contracts/AuthorizationPermissions.cs3. Create the role
Call POST /api/authorization/roles with authorization.role.create.
# API_URL is the deployment endpoint; ACCESS_TOKEN belongs to an authenticated administrator.# The role name becomes a tenant-local relation key, not a freely editable display label.curl --fail-with-body --request POST "$API_URL/api/authorization/roles" \ --header "Authorization: Bearer $ACCESS_TOKEN" \ --header "Content-Type: application/json" \ --data '{ "name": "support-manager", "description": "Handles tickets and escalations", "roleType": 1, "roleGroupId": "support" }'The handler checks name uniqueness, creates a RoleRecord, writes through the trusted tenant store, publishes RoleChangedIntegrationEvent with Created, invalidates the tenant Permission cache, and returns RoleDto.
public static class AuthorizationErrors{ public static readonly Error RoleNameAlreadyExists = Error.Conflict( AuthorizationErrorCodes.RoleNameAlreadyExists, "Role name already exists.");}
var duplicate = await roleStore.ExistsByNameAsync(request.Name, cancellationToken);if (duplicate.IsFailure){ return Result.Failure<RoleDto>(duplicate.Error);}
// Name is a tenant-local relation key; a duplicate is a conflict, not another role root.if (duplicate.Value){ return Result.Failure<RoleDto>( AuthorizationErrors.RoleNameAlreadyExists.WithDescription($"Role '{request.Name}' already exists"));}
// The Store obtains trusted tenant context and generates the persistence ID.var role = new RoleRecord{ Name = request.Name, Description = request.Description, RoleType = request.RoleType, RoleGroupId = request.RoleGroupId, IsActive = true, IsEnabled = true,};
var inserted = await roleStore.InsertAsync(role, cancellationToken);Verify that the returned ID is the persistence ID, Name is the stable relation key, a duplicate in the same tenant conflicts, the same name in another tenant is isolated, and event tenant/actor fields come from trusted context.
4. Grant a ticket permission
Call POST /api/authorization/roles/{roleId}/permissions with moduleId and permissionId. RoleStore accepts the database role ID or name, normalizes it to the tenant-local role name, and verifies that the permission exists, is enabled, and belongs to the supplied Menu module.
# The caller needs role-permission.grant; do not provision an invented assign variant.# Module and permission must still resolve to the same enabled catalog node.curl --fail-with-body --request POST \ "$API_URL/api/authorization/roles/$ROLE_ID/permissions" \ --header "Authorization: Bearer $ACCESS_TOKEN" \ --header "Content-Type: application/json" \ --data '{ "moduleId": "tickets", "permissionId": "tickets.tickets.read" }'The success path rejects duplicate relations, inserts RolePermissionRecord, publishes PermissionChangedIntegrationEvent, and invalidates the tenant Permission cache. Reference integrity is enforced in the Store; there is no role.Grant() aggregate method.
5. Assign the role to Alice
Call POST /api/authorization/users/{userId}/roles/{roleId} with authorization.user-role.assign. The user ID is an Identity-owned business key. The Store confirms that the user belongs to the trusted tenant and that the role is active and enabled.
# Resolve USER_ID and ROLE_ID from trusted query output, not display text.curl --fail-with-body --request POST \ "$API_URL/api/authorization/users/$USER_ID/roles/$ROLE_ID" \ --header "Authorization: Bearer $ACCESS_TOKEN"A duplicate returns Authorization.UserRoleAlreadyAssigned. Success writes IsGranted = true and publishes a RoleAssigned permission event.
Target-subject cache and menu synchronization
After the write and event, the handler calls AuthorizationSubjectCacheInvalidation.InvalidateWithMenuAsync with target request.UserId. It invalidates the target Permission cache and publishes local-resource synchronization so every instance refreshes the menu projection. A regression must not return to actor-only or process-local invalidation.
[Fact]public async Task AssignRole_Should_Invalidate_Target_Subject_Not_Actor(){ // Keep actor and target different so an incorrect implementation cannot pass. var handler = CreateHandler(actorSubjectKey: "admin-1", permissionCache: cache); var command = new AssignUserRole.AssignUserRoleCommand("user-2", "support-manager");
// After the write, only target 2002 may appear in the precise invalidation record. var result = await handler.Handle(command, CancellationToken.None);
result.IsSuccess.Should().BeTrue(); cache.InvalidatedUsers.Should().ContainSingle(x => x.TenantId == TenantId && x.UserId == "user-2"); cache.InvalidatedUsers.Should().NotContain(x => x.UserId == "admin-1"); menuSync.PublishedSubjects.Should().Contain("user-2");}Revocation follows the same target-subject contract. A cache or synchronization failure must not be reported as fully converged success; retry or reconcile from the stable error.
6. Verify the effective subject
Management reads prove that rows exist; they do not prove Alice’s runtime subject contains the effective permission.
# Confirm the user-role row is granted and not soft-deleted.curl --fail-with-body \ "$API_URL/api/authorization/users/$USER_ID/roles" \ --header "Authorization: Bearer $ACCESS_TOKEN"
# Confirm permission normalization to the expected module and stable code.curl --fail-with-body \ "$API_URL/api/authorization/roles/$ROLE_ID/permissions" \ --header "Authorization: Bearer $ACCESS_TOKEN"Then refresh Alice’s trusted subject, verify CurrentUser.Permissions contains the code, call a tickets/tickets Read request, inspect the RBAC audit match, revoke the permission, and prove the old Allow no longer hits cache.
7. Revoke and delete in dependency order
DeleteRole checks HasBoundUsersAsync and returns Authorization.RoleHasBoundUsers instead of cascading. Deletion sets IsDeleted, clears Active, and clears Enabled.
# The DELETE routes require user-role.revoke and role-permission.revoke respectively.curl --fail-with-body --request DELETE \ "$API_URL/api/authorization/users/$USER_ID/roles/$ROLE_ID" \ --header "Authorization: Bearer $ACCESS_TOKEN"
curl --fail-with-body --request DELETE \ "$API_URL/api/authorization/roles/$ROLE_ID/permissions/tickets.tickets.read" \ --header "Authorization: Bearer $ACCESS_TOKEN"
# Role deletion succeeds only after user bindings are removed.curl --fail-with-body --request DELETE \ "$API_URL/api/authorization/roles/$ROLE_ID" \ --header "Authorization: Bearer $ACCESS_TOKEN"8. Transaction, event, and idempotency boundaries
Handlers execute Store write, integration event publication, and cache invalidation in that order. Do not infer atomicity from sequence alone. It depends on the production UnitOfWork and publisher composition. Store conflict checks provide friendly errors; database unique indexes remain the concurrent-write guard; consumers deduplicate integration events by EventId.
9. Release acceptance
- Every action suffix matches
AuthorizationPermissions, with architecture coverage for new orphan codes. - Assignment and revocation invalidate the target subject’s decisions and menu projection.
- A referenced role name cannot change under either ORM.
- Role, user, and permission references are tenant-safe.
- Duplicate assignment, duplicate grant, and bound-role deletion return stable errors.
- Store, event, and cache failure combinations have integration coverage.
- The next request after revocation cannot use a stale Allow.