Introduce role and user management services (#7297)

* Introduce role and user management services

- Add `CreateRoleResult` and `CreateUserResult` models for managing creation results.
- Implement `IRoleManager` and `IUserManager` interfaces and their service classes.
- Configure dependency injection for role and user managers.
- Refactor endpoints to use new manager interfaces.

* Remove IRoleStore dependency from Create endpoint
This commit is contained in:
Sipke Schoorstra 2026-02-15 18:50:11 +01:00 committed by GitHub
parent c99a594eb3
commit 1b09538a13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 192 additions and 129 deletions

View file

@ -0,0 +1,20 @@
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
namespace Elsa.Identity.Contracts;
/// <summary>
/// Manages role operations such as creation.
/// </summary>
public interface IRoleManager
{
/// <summary>
/// Creates a new role with the specified details.
/// </summary>
/// <param name="name">The role name.</param>
/// <param name="permissions">The permissions to assign to the role. If null, defaults to an empty list.</param>
/// <param name="id">The optional role ID. If null, will be generated from the name.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing the created role.</returns>
Task<CreateRoleResult> CreateRoleAsync(string name, ICollection<string>? permissions = null, string? id = null, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,20 @@
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
namespace Elsa.Identity.Contracts;
/// <summary>
/// Manages user operations such as creation and validation.
/// </summary>
public interface IUserManager
{
/// <summary>
/// Creates a new user with the specified details.
/// </summary>
/// <param name="name">The user name (typically email).</param>
/// <param name="password">The user's password. If null or empty, a password will be generated.</param>
/// <param name="roles">The roles to assign to the user. If null, defaults to an empty list.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing the created user and the plain-text password.</returns>
Task<CreateUserResult> CreateUserAsync(string name, string? password = null, ICollection<string>? roles = null, CancellationToken cancellationToken = default);
}

View file

@ -1,8 +1,5 @@
using Elsa.Abstractions;
using Elsa.Abstractions;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Workflows;
using Humanizer;
using JetBrains.Annotations;
namespace Elsa.Identity.Endpoints.Roles.Create;
@ -11,28 +8,8 @@ namespace Elsa.Identity.Endpoints.Roles.Create;
/// An endpoint that creates a new role.
/// </summary>
[PublicAPI]
internal class Create : ElsaEndpoint<Request, Response>
internal class Create(IRoleManager roleManager) : ElsaEndpoint<Request, Response>
{
private readonly IIdentityGenerator _identityGenerator;
private readonly ISecretGenerator _secretGenerator;
private readonly ISecretHasher _secretHasher;
private readonly IUserStore _userStore;
private readonly IRoleStore _roleStore;
public Create(
IIdentityGenerator identityGenerator,
ISecretGenerator secretGenerator,
ISecretHasher secretHasher,
IUserStore userStore,
IRoleStore roleStore)
{
_identityGenerator = identityGenerator;
_secretGenerator = secretGenerator;
_secretHasher = secretHasher;
_userStore = userStore;
_roleStore = roleStore;
}
/// <inheritdoc />
public override void Configure()
{
@ -44,21 +21,16 @@ internal class Create : ElsaEndpoint<Request, Response>
/// <inheritdoc />
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
var id = request.Id ?? request.Name.Kebaberize();
var role = new Role
{
Id = id,
Name = request.Name,
Permissions = request.Permissions ?? new List<string>()
};
await _roleStore.SaveAsync(role, cancellationToken);
var result = await roleManager.CreateRoleAsync(
request.Name,
request.Permissions,
request.Id,
cancellationToken);
var response = new Response(
id,
role.Name,
role.Permissions);
result.Role.Id,
result.Role.Name,
result.Role.Permissions);
await Send.OkAsync(response, cancellationToken);
}

View file

@ -1,7 +1,5 @@
using Elsa.Abstractions;
using Elsa.Abstractions;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Workflows;
using JetBrains.Annotations;
namespace Elsa.Identity.Endpoints.Users.Create;
@ -10,28 +8,8 @@ namespace Elsa.Identity.Endpoints.Users.Create;
/// An endpoint that creates a new user. Requires the <code>SecurityRoot</code> policy.
/// </summary>
[PublicAPI]
internal class Create : ElsaEndpoint<Request, Response>
internal class Create(IUserManager userManager) : ElsaEndpoint<Request, Response>
{
private readonly IIdentityGenerator _identityGenerator;
private readonly ISecretGenerator _secretGenerator;
private readonly ISecretHasher _secretHasher;
private readonly IUserStore _userStore;
private readonly IRoleStore _roleStore;
public Create(
IIdentityGenerator identityGenerator,
ISecretGenerator secretGenerator,
ISecretHasher secretHasher,
IUserStore userStore,
IRoleStore roleStore)
{
_identityGenerator = identityGenerator;
_secretGenerator = secretGenerator;
_secretHasher = secretHasher;
_userStore = userStore;
_roleStore = roleStore;
}
/// <inheritdoc />
public override void Configure()
{
@ -43,29 +21,20 @@ internal class Create : ElsaEndpoint<Request, Response>
/// <inheritdoc />
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
var id = _identityGenerator.GenerateId();
var password = string.IsNullOrWhiteSpace(request.Password) ? _secretGenerator.Generate() : request.Password.Trim();
var hashedPassword = _secretHasher.HashSecret(password);
var user = new User
{
Id = id,
Name = request.Name,
Roles = request.Roles ?? new List<string>(),
HashedPassword = hashedPassword.EncodeSecret(),
HashedPasswordSalt = hashedPassword.EncodeSalt()
};
await _userStore.SaveAsync(user, cancellationToken);
var result = await userManager.CreateUserAsync(
request.Name,
request.Password,
request.Roles,
cancellationToken);
var response = new Response(
id,
user.Name,
password,
user.Roles,
user.TenantId,
hashedPassword.EncodeSecret(),
hashedPassword.EncodeSalt());
result.User.Id,
result.User.Name,
result.Password,
result.User.Roles,
result.User.TenantId,
result.User.HashedPassword,
result.User.HashedPasswordSalt);
await Send.OkAsync(response, cancellationToken);
}

View file

@ -189,6 +189,8 @@ public class IdentityFeature : FeatureBase
.AddScoped(UserProvider)
.AddScoped(ApplicationProvider)
.AddScoped(RoleProvider)
.AddScoped<IUserManager, UserManager>()
.AddScoped<IRoleManager, RoleManager>()
.AddScoped<ISecretHasher, DefaultSecretHasher>()
.AddScoped<IAccessTokenIssuer, DefaultAccessTokenIssuer>()
.AddScoped<IUserCredentialsValidator, DefaultUserCredentialsValidator>()

View file

@ -0,0 +1,9 @@
using Elsa.Identity.Entities;
namespace Elsa.Identity.Models;
/// <summary>
/// Result of a role creation operation.
/// </summary>
/// <param name="Role">The created role entity.</param>
public record CreateRoleResult(Role Role);

View file

@ -0,0 +1,10 @@
using Elsa.Identity.Entities;
namespace Elsa.Identity.Models;
/// <summary>
/// Result of a user creation operation.
/// </summary>
/// <param name="User">The created user entity.</param>
/// <param name="Password">The plain-text password (either provided or generated).</param>
public record CreateUserResult(User User, string Password);

View file

@ -0,0 +1,40 @@
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Humanizer;
namespace Elsa.Identity.Services;
/// <summary>
/// Default implementation of <see cref="IRoleManager"/>.
/// </summary>
public class RoleManager : IRoleManager
{
private readonly IRoleStore _roleStore;
public RoleManager(IRoleStore roleStore)
{
_roleStore = roleStore;
}
/// <inheritdoc />
public async Task<CreateRoleResult> CreateRoleAsync(
string name,
ICollection<string>? permissions = null,
string? id = null,
CancellationToken cancellationToken = default)
{
var roleId = id ?? name.Kebaberize();
var role = new Role
{
Id = roleId,
Name = name,
Permissions = permissions ?? new List<string>()
};
await _roleStore.SaveAsync(role, cancellationToken);
return new CreateRoleResult(role);
}
}

View file

@ -0,0 +1,54 @@
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Workflows;
namespace Elsa.Identity.Services;
/// <summary>
/// Default implementation of <see cref="IUserManager"/>.
/// </summary>
public class UserManager : IUserManager
{
private readonly IIdentityGenerator _identityGenerator;
private readonly ISecretGenerator _secretGenerator;
private readonly ISecretHasher _secretHasher;
private readonly IUserStore _userStore;
public UserManager(
IIdentityGenerator identityGenerator,
ISecretGenerator secretGenerator,
ISecretHasher secretHasher,
IUserStore userStore)
{
_identityGenerator = identityGenerator;
_secretGenerator = secretGenerator;
_secretHasher = secretHasher;
_userStore = userStore;
}
/// <inheritdoc />
public async Task<CreateUserResult> CreateUserAsync(
string name,
string? password = null,
ICollection<string>? roles = null,
CancellationToken cancellationToken = default)
{
var id = _identityGenerator.GenerateId();
var plainTextPassword = string.IsNullOrWhiteSpace(password) ? _secretGenerator.Generate() : password.Trim();
var hashedPassword = _secretHasher.HashSecret(plainTextPassword);
var user = new User
{
Id = id,
Name = name,
Roles = roles ?? new List<string>(),
HashedPassword = hashedPassword.EncodeSecret(),
HashedPasswordSalt = hashedPassword.EncodeSalt()
};
await _userStore.SaveAsync(user, cancellationToken);
return new CreateUserResult(user, plainTextPassword);
}
}

View file

@ -28,11 +28,6 @@ public class DefaultAuthenticationFeature : IShellFeature
/// </summary>
public Type ApiKeyProviderType { get; set; } = typeof(DefaultApiKeyProvider);
/// <summary>
/// Gets or sets whether to require localhost for the security root policy.
/// </summary>
public bool RequireLocalHost { get; set; } = true;
public void ConfigureServices(IServiceCollection services)
{
services.ConfigureOptions<ConfigureJwtBearerOptions>();
@ -64,10 +59,7 @@ public class DefaultAuthenticationFeature : IShellFeature
services.AddAuthorization(options =>
{
if (RequireLocalHost)
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.AddRequirements(new LocalHostPermissionRequirement()));
else
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.RequireAuthenticatedUser());
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.RequireAuthenticatedUser());
});
}
}

View file

@ -24,36 +24,6 @@ namespace Elsa.Identity.ShellFeatures;
[UsedImplicitly]
public class IdentityFeature : IFastEndpointsShellFeature
{
/// <summary>
/// A delegate that creates an instance of an implementation of <see cref="IUserStore"/>.
/// </summary>
public Func<IServiceProvider, IUserStore> UserStore { get; set; } = sp => sp.GetRequiredService<MemoryUserStore>();
/// <summary>
/// A delegate that creates an instance of an implementation of <see cref="IApplicationStore"/>.
/// </summary>
public Func<IServiceProvider, IApplicationStore> ApplicationStore { get; set; } = sp => sp.GetRequiredService<MemoryApplicationStore>();
/// <summary>
/// A delegate that creates an instance of an implementation of <see cref="IRoleStore"/>.
/// </summary>
public Func<IServiceProvider, IRoleStore> RoleStore { get; set; } = sp => sp.GetRequiredService<MemoryRoleStore>();
/// <summary>
/// A delegate that creates an instance of an implementation of <see cref="IUserProvider"/>.
/// </summary>
public Func<IServiceProvider, IUserProvider> UserProvider { get; set; } = sp => sp.GetRequiredService<AdminUserProvider>();
/// <summary>
/// A delegate that creates an instance of an implementation of <see cref="IApplicationProvider"/>.
/// </summary>
public Func<IServiceProvider, IApplicationProvider> ApplicationProvider { get; set; } = sp => sp.GetRequiredService<StoreBasedApplicationProvider>();
/// <summary>
/// A delegate that creates an instance of an implementation of <see cref="IRoleProvider"/>.
/// </summary>
public Func<IServiceProvider, IRoleProvider> RoleProvider { get; set; } = sp => sp.GetRequiredService<AdminRoleProvider>();
public void ConfigureServices(IServiceCollection services)
{
// Configure options - Note: SigningKey must be configured by the application for security
@ -97,12 +67,8 @@ public class IdentityFeature : IFastEndpointsShellFeature
// Services.
services
.AddScoped(UserStore)
.AddScoped(ApplicationStore)
.AddScoped(RoleStore)
.AddScoped(UserProvider)
.AddScoped(ApplicationProvider)
.AddScoped(RoleProvider)
.AddScoped<IUserManager, UserManager>()
.AddScoped<IRoleManager, RoleManager>()
.AddScoped<ISecretHasher, DefaultSecretHasher>()
.AddScoped<IAccessTokenIssuer, DefaultAccessTokenIssuer>()
.AddScoped<IUserCredentialsValidator, DefaultUserCredentialsValidator>()
@ -115,5 +81,14 @@ public class IdentityFeature : IFastEndpointsShellFeature
.AddScoped<DefaultApiKeyGeneratorAndParser>()
.AddHttpContextAccessor()
;
// Overridable services.
services
.AddScoped<IUserStore, MemoryUserStore>()
.AddScoped<IApplicationStore, MemoryApplicationStore>()
.AddScoped<IRoleStore, MemoryRoleStore>()
.AddScoped<IUserProvider, StoreBasedUserProvider>()
.AddScoped<IApplicationProvider, StoreBasedApplicationProvider>()
.AddScoped<IRoleProvider, StoreBasedRoleProvider>();
}
}
}