Skip to content
bitzorcas
中EN

Guide

Authorization RBAC management and role lifecycle

Follow a support-manager role through creation, permission grant, user assignment, verification, revocation, and deletion, including stable keys and cache gates.

Last updated

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

UserRoleRolePermissionRolePermission

Alice
Identity subject key

support-manager
tenant-local role name

tickets.tickets.read

tickets.tickets.write

CurrentUser.Permissions

RBAC evaluator

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 caseActionDerived and cataloged requirement
Grant role permissionGrantauthorization.role-permission.grant
Revoke role permissionRevokeauthorization.role-permission.revoke
Assign user roleAssignauthorization.user-role.assign
Revoke user roleRevokeauthorization.user-role.revoke
Terminal window
# 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.cs

3. Create the role

Call POST /api/authorization/roles with authorization.role.create.

Create support-manager
# 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.

Actual role-creation coordination boundary
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.

Grant ticket read
# 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.

Assign support-manager
# 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.

Regression test for target-user 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.

Read user-role and role-permission relations
# 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

Revoke UserRole
IsGranted=false

Revoke RolePermission
IsGranted=false

Delete Role
soft-delete and disable

Invalidate tenant Permission cache

DeleteRole checks HasBoundUsersAsync and returns Authorization.RoleHasBoundUsers instead of cascading. Deletion sets IsDeleted, clears Active, and clears Enabled.

Clean up in safe order
# 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.

Previous: decision engine · Next: ABAC, ReBAC, and Feature

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%