Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ is separate work. Existing assignments need explicit section grants for UI visib
The old `/api/v1/permissions` route is replaced by `/api/v1/claims/permissions`.
Frontend adaptation is separate; no database migration is required.

## User Roles

Identity seeds `developer`, `moderator`, and `player` at startup using the existing
lowercase role-name convention. Seeding preserves existing role IDs and claims;
new roles have no permission/section claims. Public registration assigns Developer.
Existing users are not reassigned automatically.

User create/update requests accept optional `roleIds` (GUID array); get-details
returns the selected IDs. On update, an omitted/null field preserves roles and `[]`
clears them. On administrative creation, omitted/null means no roles. Empty GUIDs,
duplicates, and unknown role IDs are rejected before persistence. Use the existing
roles list endpoint to populate selection options. Role changes rotate the security
stamp and revoke the user's tokens. No database migration or new configuration is required.

## Request Validation

Request DTO constructors and nullable annotations define the JSON contract and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ public sealed record CreateUserCommand(
string Password,
bool IsConfirmed,
IReadOnlyCollection<Claim> Claims,
string Status) : ICommand<Result<Guid>>;
string Status,
IReadOnlyCollection<Guid>? RoleIds = null) : ICommand<Result<Guid>>;
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using PANiXiDA.TacticalHeroes.Identity.Application.Claims;
using PANiXiDA.TacticalHeroes.Identity.Domain.Users;
using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Entities.UserClaims.ValueObjects;
using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations;
using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects;
Expand Down Expand Up @@ -32,6 +33,9 @@ public CreateUserCommandValidator()
})
.SetValidator(new ClaimCatalogValidator());

RuleFor(command => command.RoleIds)
.MustBeValidDomainValue(UserRoleAssignments.Create);

RuleFor(command => command.Status)
.MustBeValidDomainValue(UserStatus.Create);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public Task<Result<Guid>> HandleAsync(
var emailResult = Email.Create(value: command.Email);
var userNameResult = UserName.Create(value: command.UserName);
var statusResult = UserStatus.Create(value: command.Status);
var validationResult = Result.Combine(emailResult, userNameResult, statusResult);
var roleIdsResult = UserRoleAssignments.Create(command.RoleIds);
var validationResult = Result.Combine(emailResult, userNameResult, statusResult, roleIdsResult);

if (validationResult.IsFailure)
{
Expand Down Expand Up @@ -46,7 +47,7 @@ public Task<Result<Guid>> HandleAsync(
userName: userNameResult.Value,
status: statusResult.Value,
confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed),
roleIds: [],
roleIds: roleIdsResult.Value,
claims: claims);

return usersRepository.AddAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public sealed record UserDetailsReadModel(
string StatusDisplayName,
IReadOnlyCollection<Claim> Claims) : IReadModel
{
public IReadOnlyCollection<Guid> RoleIds { get; init; } = [];

public bool IsBlocked => string.Equals(
Status,
UserStatus.Blocked.Name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ public sealed record UpdateUserCommand(
string UserName,
bool IsConfirmed,
IReadOnlyCollection<Claim> Claims,
string Status) : ICommand<Result>;
string Status,
IReadOnlyCollection<Guid>? RoleIds = null) : ICommand<Result>;
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public UpdateUserCommandValidator()
})
.SetValidator(new ClaimCatalogValidator());

RuleFor(command => command.RoleIds)
.MustBeValidDomainValue(UserRoleAssignments.Create);

RuleFor(command => command.Status)
.MustBeValidDomainValue(UserStatus.Create);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public Task<Result> HandleAsync(
var emailResult = Email.Create(value: command.Email);
var userNameResult = UserName.Create(value: command.UserName);
var statusResult = UserStatus.Create(value: command.Status);
var validationResult = Result.Combine(idResult, emailResult, userNameResult, statusResult);
var roleIdsResult = UserRoleAssignments.Create(command.RoleIds);
var validationResult = Result.Combine(idResult, emailResult, userNameResult, statusResult, roleIdsResult);

if (validationResult.IsFailure)
{
Expand Down Expand Up @@ -47,11 +48,12 @@ public Task<Result> HandleAsync(
userName: userNameResult.Value,
status: statusResult.Value,
confirmationStatus: UserConfirmationStatus.From(isConfirmed: command.IsConfirmed),
roleIds: [],
roleIds: roleIdsResult.Value,
claims: claims);

return usersRepository.UpdateAsync(
user: user,
cancellationToken: cancellationToken);
cancellationToken: cancellationToken,
replaceRoles: command.RoleIds is not null);
}
}
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
Expand Up @@ -9,7 +9,8 @@ Task<Result<Guid>> AddAsync(

Task<Result> UpdateAsync(
User user,
CancellationToken cancellationToken);
CancellationToken cancellationToken,
bool replaceRoles = true);

Task<Result> DeleteAsync(
Guid id,
Expand Down
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
Expand Up @@ -37,6 +37,7 @@ public static IServiceCollection AddIdentityProvider(
.ValidateOnStart();

serviceCollection.AddScoped<IUserCredentialsService, UserCredentialsService>();
serviceCollection.AddScoped<IdentityRoleSeeder>();
serviceCollection.AddScoped<IdentityProviderApplicationSeeder>();
serviceCollection.AddHostedService<IdentityProviderApplicationSeederHostedService>();
serviceCollection.AddDataProtection()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ internal sealed class IdentityProviderApplicationSeederHostedService(
public async Task StartAsync(CancellationToken cancellationToken)
{
await using var scope = serviceScopeFactory.CreateAsyncScope();
var roleSeeder = scope.ServiceProvider.GetRequiredService<IdentityRoleSeeder>();
await roleSeeder.SeedAsync(cancellationToken);
var seeder = scope.ServiceProvider.GetRequiredService<IdentityProviderApplicationSeeder>();

await seeder.SeedAsync(cancellationToken);
Expand Down
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;
}
Comment on lines +15 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make role seeding safe across replicas

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 from CreateAsync or the explicit exception below. Treat a duplicate created concurrently as success or serialize the seeding operation.

Useful? React with 👍 / 👎.


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
Expand Up @@ -4,12 +4,14 @@

using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Abstractions;
using PANiXiDA.TacticalHeroes.Identity.Application.Auth.Login;
using PANiXiDA.TacticalHeroes.Identity.Domain.Roles;
using PANiXiDA.TacticalHeroes.Identity.Domain.Users;
using PANiXiDA.TacticalHeroes.Identity.Domain.Users.Enumerations;
using PANiXiDA.TacticalHeroes.Identity.Domain.Users.ValueObjects;
using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Claims;
using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Mappers;
using PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Options.IdentityProvider;
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;
Expand All @@ -18,6 +20,7 @@ namespace PANiXiDA.TacticalHeroes.Identity.Infrastructure.IdentityProvider.Servi

public sealed class UserCredentialsService(
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
IOpenIddictTokenManager tokenManager,
IOptions<IdentityProviderOptions> options,
IAggregateTracker aggregateTracker,
Expand All @@ -39,7 +42,20 @@ public async Task<Result<Guid>> RegisterAsync(
return Result.Failure<Guid>(errors: validationResult.Errors);
}

var developerRole = await roleManager.FindByNameAsync(RoleNames.Developer);
if (developerRole is null)
{
return Result.Failure<Guid>(error: Error.NotFound(message: "Developer role was not found."));
}

var roleIdResult = RoleId.Create(value: developerRole.Id);
if (roleIdResult.IsFailure)
{
return Result.Failure<Guid>(errors: roleIdResult.Errors);
}

var user = User.Register(email: emailResult.Value, userName: userNameResult.Value);
user.AssignRole(roleIdResult.Value);
var nowUtc = timeProvider.GetUtcNow().UtcDateTime;
var applicationUser = ApplicationUserMapper.ToDbModel(
user: user,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
return query.Select(user => new UserDetailsReadModel(
Id: user.Id,
Email: user.Email,
UserName: user.UserName!,

Check warning on line 18 in src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs

View workflow job for this annotation

GitHub Actions / sonar / run

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
IsConfirmed: user.EmailConfirmed,
Status: user.Status,
StatusDisplayName: UserStatus.FromName(name: user.Status).DisplayName,
Claims: user.Claims
.Select(claim => new Claim(
type: claim.ClaimType!,

Check warning on line 24 in src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs

View workflow job for this annotation

GitHub Actions / sonar / run

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
value: claim.ClaimValue!))

Check warning on line 25 in src/Identity/PANiXiDA.TacticalHeroes.Identity.Infrastructure/Persistence/Features/Users/Read/GetDetails/UserDetailsReadModelMapper.cs

View workflow job for this annotation

GitHub Actions / sonar / run

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
.ToArray()));
.ToArray())
{
RoleIds = user.Roles.Select(role => role.RoleId).ToArray()
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Commit role changes only after the user update succeeds

When a user with an existing token changes roles, SyncRoles and the other mappings are already tracked in the same scoped IdentityWriteDbContext used by the OpenIddict token manager; revoking the token saves that context before UpdateSecurityStampAsync validates the user. If the request also supplies a duplicate username/email, the later Identity update can return a conflict even though the role membership and mapped fields were already persisted, potentially granting a privileged role despite the failed response. Keep revocation and the user update in a transaction or otherwise prevent token persistence from flushing the pending user graph first.

Useful? React with 👍 / 👎.

Comment on lines +85 to 87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revoke sessions when an assigned role is modified

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 RolesRepository changes the effective authorization graph without touching rolesChanged, the users' security stamps, or their OpenIddict tokens. Existing access tokens therefore retain removed role permissions until expiration despite introspection, so role updates and deletions must also invalidate tokens for every assigned user.

Useful? React with 👍 / 👎.

}

var identityResult = await userManager.UpdateAsync(applicationUser);
var identityResult = rolesChanged
? await userManager.UpdateSecurityStampAsync(applicationUser)
: await userManager.UpdateAsync(applicationUser);

if (!identityResult.Succeeded)
{
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ public sealed record CreateUserRequest(
string Password,
bool IsConfirmed,
IReadOnlyCollection<Claim> Claims,
string Status);
string Status,
IReadOnlyCollection<Guid>? RoleIds = null);
Loading
Loading