Repository navigation
feat: add built-in roles and user role assignment #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
8569a17
0779462
5bf6f0d
15d2529
3a90cf6
6176235
3d92010
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| using System.Collections.Immutable; | ||
|
|
||
| namespace PANiXiDA.TacticalHeroes.Identity.Domain.Roles; | ||
|
|
||
| public static class RoleNames | ||
| { | ||
| public const string Developer = "developer"; | ||
| public const string Moderator = "moderator"; | ||
| public const string Player = "player"; | ||
|
|
||
| public static ImmutableArray<string> All { get; } = [Developer, Moderator, Player]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; | ||
|
|
||
| namespace PANiXiDA.TacticalHeroes.Identity.Domain.Users; | ||
|
|
||
| public static class UserRoleAssignments | ||
| { | ||
| public static Result<IReadOnlyCollection<RoleId>> Create(IReadOnlyCollection<Guid>? values) | ||
| { | ||
| var roleIds = new List<RoleId>(); | ||
| var seen = new HashSet<Guid>(); | ||
| foreach (var value in values ?? []) | ||
| { | ||
| if (!seen.Add(value)) | ||
| { | ||
| return Result.Failure<IReadOnlyCollection<RoleId>>( | ||
| error: Error.Validation(message: "Role IDs must be unique.").WithField(nameof(User.RoleIds))); | ||
| } | ||
|
|
||
| var result = RoleId.Create(value); | ||
| if (result.IsFailure) | ||
| { | ||
| return Result.Failure<IReadOnlyCollection<RoleId>>(errors: result.Errors); | ||
| } | ||
|
|
||
| roleIds.Add(result.Value); | ||
| } | ||
|
|
||
| return Result.Success<IReadOnlyCollection<RoleId>>(value: roleIds); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| using Microsoft.AspNetCore.Identity; | ||
|
|
||
| using PANiXiDA.TacticalHeroes.Identity.Domain.Roles; | ||
| using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; | ||
|
|
||
| namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Seeding; | ||
|
|
||
| internal sealed class IdentityRoleSeeder(RoleManager<ApplicationRole> roleManager, TimeProvider timeProvider) | ||
| { | ||
| public async Task SeedAsync(CancellationToken cancellationToken) | ||
| { | ||
| foreach (var name in RoleNames.All) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| if (await roleManager.FindByNameAsync(name) is not null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var nowUtc = timeProvider.GetUtcNow().UtcDateTime; | ||
| var result = await roleManager.CreateAsync(new ApplicationRole | ||
| { | ||
| Id = Guid.CreateVersion7(), | ||
| Name = name, | ||
| CreatedAt = nowUtc, | ||
| UpdatedAt = nowUtc | ||
| }); | ||
| if (!result.Succeeded) | ||
| { | ||
| var errors = string.Join(", ", result.Errors.Select(error => error.Description)); | ||
| throw new InvalidOperationException($"Could not seed role '{name}': {errors}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations; | ||
| using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Mappers; | ||
| using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Core; | ||
| using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Roles.Write.DbModels; | ||
| using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.DbModels; | ||
| using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.Mappers; | ||
| using PANiXiDA.TacticalHeroes.Identity.Infrastructure.Persistence.Features.Users.Write.Queries; | ||
|
|
@@ -25,6 +26,12 @@ public async Task<Result<Guid>> AddAsync( | |
| string password, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var rolesResult = await ValidateRolesAsync(user, cancellationToken); | ||
| if (rolesResult.IsFailure) | ||
| { | ||
| return Result.Failure<Guid>(errors: rolesResult.Errors); | ||
| } | ||
|
|
||
| var nowUtc = timeProvider.GetUtcNow().UtcDateTime; | ||
| var applicationUser = ApplicationUserMapper.ToDbModel( | ||
| user: user, | ||
|
|
@@ -45,7 +52,8 @@ public async Task<Result<Guid>> AddAsync( | |
|
|
||
| public async Task<Result> UpdateAsync( | ||
| User user, | ||
| CancellationToken cancellationToken) | ||
| CancellationToken cancellationToken, | ||
| bool replaceRoles = true) | ||
| { | ||
| var applicationUser = await userManager.Users | ||
| .WithAuthorizationGraph() | ||
|
|
@@ -56,6 +64,16 @@ public async Task<Result> UpdateAsync( | |
| return UserNotFound(); | ||
| } | ||
|
|
||
| if (replaceRoles) | ||
| { | ||
| var rolesResult = await ValidateRolesAsync(user, cancellationToken); | ||
| if (rolesResult.IsFailure) | ||
| { | ||
| return rolesResult; | ||
| } | ||
| } | ||
|
|
||
| var rolesChanged = replaceRoles && SyncRoles(applicationUser, user); | ||
| ApplicationUserMapper.MapToDbModel( | ||
| user: user, | ||
| dbModel: applicationUser, | ||
|
|
@@ -64,12 +82,14 @@ public async Task<Result> UpdateAsync( | |
| applicationUser: applicationUser, | ||
| user: user); | ||
|
|
||
| if (user.Status.IsBlocked) | ||
| if (user.Status.IsBlocked || rolesChanged) | ||
| { | ||
| await RevokeAllTokensAsync(user.Id.Value, cancellationToken); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user with an existing token changes roles, Useful? React with 👍 / 👎.
Comment on lines
+85
to
87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This invalidates tokens only when a user's membership set changes. Once this change allows users to be assigned roles, updating a role's claims/name or deleting it through Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| var identityResult = await userManager.UpdateAsync(applicationUser); | ||
| var identityResult = rolesChanged | ||
| ? await userManager.UpdateSecurityStampAsync(applicationUser) | ||
| : await userManager.UpdateAsync(applicationUser); | ||
|
|
||
| if (!identityResult.Succeeded) | ||
| { | ||
|
|
@@ -164,6 +184,46 @@ private async Task RevokeAllTokensAsync( | |
| await tokenManager.RevokeBySubjectAsync(id.ToString(), cancellationToken); | ||
| } | ||
|
|
||
| private async Task<Result> ValidateRolesAsync(User user, CancellationToken cancellationToken) | ||
| { | ||
| var roleIds = user.RoleIds.Select(roleId => roleId.Value).ToArray(); | ||
| if (roleIds.Length == 0) | ||
| { | ||
| return Result.Success(); | ||
| } | ||
|
|
||
| var count = await dbContext.Set<ApplicationRole>() | ||
| .CountAsync(role => roleIds.Contains(role.Id), cancellationToken); | ||
| if (count != roleIds.Length) | ||
| { | ||
| return Result.Failure(error: Error.Validation(message: "One or more roles were not found.") | ||
| .WithField(nameof(User.RoleIds))); | ||
| } | ||
|
|
||
| return Result.Success(); | ||
| } | ||
|
|
||
| private bool SyncRoles(ApplicationUser applicationUser, User user) | ||
| { | ||
| var roleIds = user.RoleIds.Select(roleId => roleId.Value).ToHashSet(); | ||
| if (roleIds.SetEquals(applicationUser.Roles.Select(role => role.RoleId))) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| foreach (var currentRole in applicationUser.Roles.Where(role => !roleIds.Contains(role.RoleId)).ToArray()) | ||
| { | ||
| dbContext.Set<ApplicationUserRole>().Remove(currentRole); | ||
| } | ||
|
|
||
| foreach (var roleId in roleIds.Where(id => applicationUser.Roles.All(role => role.RoleId != id))) | ||
| { | ||
| applicationUser.Roles.Add(new ApplicationUserRole { UserId = applicationUser.Id, RoleId = roleId }); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| private void SyncClaims( | ||
| ApplicationUser applicationUser, | ||
| User user) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The production chart was checked and configures two replicas in
deploy/helm/tactical-heroes-api/values-production.yaml; during the first deployment, both hosted services can observe a missing role here and then race to create it. The losing replica encounters the unique normalized-role-name constraint and fails startup, either fromCreateAsyncor the explicit exception below. Treat a duplicate created concurrently as success or serialize the seeding operation.Useful? React with 👍 / 👎.