From 1b09538a137be6e952375a8230a37158492a2343 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 15 Feb 2026 18:50:11 +0100 Subject: [PATCH] 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 --- .../Elsa.Identity/Contracts/IRoleManager.cs | 20 +++++++ .../Elsa.Identity/Contracts/IUserManager.cs | 20 +++++++ .../Endpoints/Roles/Create/Endpoint.cs | 48 ++++----------- .../Endpoints/Users/Create/Endpoint.cs | 59 +++++-------------- .../Elsa.Identity/Features/IdentityFeature.cs | 2 + .../Elsa.Identity/Models/CreateRoleResult.cs | 9 +++ .../Elsa.Identity/Models/CreateUserResult.cs | 10 ++++ .../Elsa.Identity/Services/RoleManager.cs | 40 +++++++++++++ .../Elsa.Identity/Services/UserManager.cs | 54 +++++++++++++++++ .../DefaultAuthenticationFeature.cs | 10 +--- .../ShellFeatures/IdentityFeature.cs | 49 ++++----------- 11 files changed, 192 insertions(+), 129 deletions(-) create mode 100644 src/modules/Elsa.Identity/Contracts/IRoleManager.cs create mode 100644 src/modules/Elsa.Identity/Contracts/IUserManager.cs create mode 100644 src/modules/Elsa.Identity/Models/CreateRoleResult.cs create mode 100644 src/modules/Elsa.Identity/Models/CreateUserResult.cs create mode 100644 src/modules/Elsa.Identity/Services/RoleManager.cs create mode 100644 src/modules/Elsa.Identity/Services/UserManager.cs diff --git a/src/modules/Elsa.Identity/Contracts/IRoleManager.cs b/src/modules/Elsa.Identity/Contracts/IRoleManager.cs new file mode 100644 index 000000000..51f509068 --- /dev/null +++ b/src/modules/Elsa.Identity/Contracts/IRoleManager.cs @@ -0,0 +1,20 @@ +using Elsa.Identity.Entities; +using Elsa.Identity.Models; + +namespace Elsa.Identity.Contracts; + +/// +/// Manages role operations such as creation. +/// +public interface IRoleManager +{ + /// + /// Creates a new role with the specified details. + /// + /// The role name. + /// The permissions to assign to the role. If null, defaults to an empty list. + /// The optional role ID. If null, will be generated from the name. + /// The cancellation token. + /// A result containing the created role. + Task CreateRoleAsync(string name, ICollection? permissions = null, string? id = null, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Identity/Contracts/IUserManager.cs b/src/modules/Elsa.Identity/Contracts/IUserManager.cs new file mode 100644 index 000000000..db6d1b21b --- /dev/null +++ b/src/modules/Elsa.Identity/Contracts/IUserManager.cs @@ -0,0 +1,20 @@ +using Elsa.Identity.Entities; +using Elsa.Identity.Models; + +namespace Elsa.Identity.Contracts; + +/// +/// Manages user operations such as creation and validation. +/// +public interface IUserManager +{ + /// + /// Creates a new user with the specified details. + /// + /// The user name (typically email). + /// The user's password. If null or empty, a password will be generated. + /// The roles to assign to the user. If null, defaults to an empty list. + /// The cancellation token. + /// A result containing the created user and the plain-text password. + Task CreateUserAsync(string name, string? password = null, ICollection? roles = null, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Identity/Endpoints/Roles/Create/Endpoint.cs b/src/modules/Elsa.Identity/Endpoints/Roles/Create/Endpoint.cs index 0b4d68f97..5405aef12 100644 --- a/src/modules/Elsa.Identity/Endpoints/Roles/Create/Endpoint.cs +++ b/src/modules/Elsa.Identity/Endpoints/Roles/Create/Endpoint.cs @@ -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. /// [PublicAPI] -internal class Create : ElsaEndpoint +internal class Create(IRoleManager roleManager) : ElsaEndpoint { - 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; - } - /// public override void Configure() { @@ -44,21 +21,16 @@ internal class Create : ElsaEndpoint /// 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() - }; - - 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); } diff --git a/src/modules/Elsa.Identity/Endpoints/Users/Create/Endpoint.cs b/src/modules/Elsa.Identity/Endpoints/Users/Create/Endpoint.cs index 52d216620..15ad92dee 100644 --- a/src/modules/Elsa.Identity/Endpoints/Users/Create/Endpoint.cs +++ b/src/modules/Elsa.Identity/Endpoints/Users/Create/Endpoint.cs @@ -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 SecurityRoot policy. /// [PublicAPI] -internal class Create : ElsaEndpoint +internal class Create(IUserManager userManager) : ElsaEndpoint { - 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; - } - /// public override void Configure() { @@ -43,29 +21,20 @@ internal class Create : ElsaEndpoint /// 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(), - 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); } diff --git a/src/modules/Elsa.Identity/Features/IdentityFeature.cs b/src/modules/Elsa.Identity/Features/IdentityFeature.cs index 9fab9f58e..e5119971c 100644 --- a/src/modules/Elsa.Identity/Features/IdentityFeature.cs +++ b/src/modules/Elsa.Identity/Features/IdentityFeature.cs @@ -189,6 +189,8 @@ public class IdentityFeature : FeatureBase .AddScoped(UserProvider) .AddScoped(ApplicationProvider) .AddScoped(RoleProvider) + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/src/modules/Elsa.Identity/Models/CreateRoleResult.cs b/src/modules/Elsa.Identity/Models/CreateRoleResult.cs new file mode 100644 index 000000000..48a16b7ea --- /dev/null +++ b/src/modules/Elsa.Identity/Models/CreateRoleResult.cs @@ -0,0 +1,9 @@ +using Elsa.Identity.Entities; + +namespace Elsa.Identity.Models; + +/// +/// Result of a role creation operation. +/// +/// The created role entity. +public record CreateRoleResult(Role Role); diff --git a/src/modules/Elsa.Identity/Models/CreateUserResult.cs b/src/modules/Elsa.Identity/Models/CreateUserResult.cs new file mode 100644 index 000000000..3a7159102 --- /dev/null +++ b/src/modules/Elsa.Identity/Models/CreateUserResult.cs @@ -0,0 +1,10 @@ +using Elsa.Identity.Entities; + +namespace Elsa.Identity.Models; + +/// +/// Result of a user creation operation. +/// +/// The created user entity. +/// The plain-text password (either provided or generated). +public record CreateUserResult(User User, string Password); diff --git a/src/modules/Elsa.Identity/Services/RoleManager.cs b/src/modules/Elsa.Identity/Services/RoleManager.cs new file mode 100644 index 000000000..da9c431be --- /dev/null +++ b/src/modules/Elsa.Identity/Services/RoleManager.cs @@ -0,0 +1,40 @@ +using Elsa.Identity.Contracts; +using Elsa.Identity.Entities; +using Elsa.Identity.Models; +using Humanizer; + +namespace Elsa.Identity.Services; + +/// +/// Default implementation of . +/// +public class RoleManager : IRoleManager +{ + private readonly IRoleStore _roleStore; + + public RoleManager(IRoleStore roleStore) + { + _roleStore = roleStore; + } + + /// + public async Task CreateRoleAsync( + string name, + ICollection? 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() + }; + + await _roleStore.SaveAsync(role, cancellationToken); + + return new CreateRoleResult(role); + } +} diff --git a/src/modules/Elsa.Identity/Services/UserManager.cs b/src/modules/Elsa.Identity/Services/UserManager.cs new file mode 100644 index 000000000..cfd2829da --- /dev/null +++ b/src/modules/Elsa.Identity/Services/UserManager.cs @@ -0,0 +1,54 @@ +using Elsa.Identity.Contracts; +using Elsa.Identity.Entities; +using Elsa.Identity.Models; +using Elsa.Workflows; + +namespace Elsa.Identity.Services; + +/// +/// Default implementation of . +/// +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; + } + + /// + public async Task CreateUserAsync( + string name, + string? password = null, + ICollection? 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(), + HashedPassword = hashedPassword.EncodeSecret(), + HashedPasswordSalt = hashedPassword.EncodeSalt() + }; + + await _userStore.SaveAsync(user, cancellationToken); + + return new CreateUserResult(user, plainTextPassword); + } +} diff --git a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs index c31783057..2d2912dd6 100644 --- a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs +++ b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs @@ -28,11 +28,6 @@ public class DefaultAuthenticationFeature : IShellFeature /// public Type ApiKeyProviderType { get; set; } = typeof(DefaultApiKeyProvider); - /// - /// Gets or sets whether to require localhost for the security root policy. - /// - public bool RequireLocalHost { get; set; } = true; - public void ConfigureServices(IServiceCollection services) { services.ConfigureOptions(); @@ -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()); }); } } diff --git a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs index a4b46679d..702e645c0 100644 --- a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs +++ b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs @@ -24,36 +24,6 @@ namespace Elsa.Identity.ShellFeatures; [UsedImplicitly] public class IdentityFeature : IFastEndpointsShellFeature { - /// - /// A delegate that creates an instance of an implementation of . - /// - public Func UserStore { get; set; } = sp => sp.GetRequiredService(); - - /// - /// A delegate that creates an instance of an implementation of . - /// - public Func ApplicationStore { get; set; } = sp => sp.GetRequiredService(); - - /// - /// A delegate that creates an instance of an implementation of . - /// - public Func RoleStore { get; set; } = sp => sp.GetRequiredService(); - - /// - /// A delegate that creates an instance of an implementation of . - /// - public Func UserProvider { get; set; } = sp => sp.GetRequiredService(); - - /// - /// A delegate that creates an instance of an implementation of . - /// - public Func ApplicationProvider { get; set; } = sp => sp.GetRequiredService(); - - /// - /// A delegate that creates an instance of an implementation of . - /// - public Func RoleProvider { get; set; } = sp => sp.GetRequiredService(); - 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() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -115,5 +81,14 @@ public class IdentityFeature : IFastEndpointsShellFeature .AddScoped() .AddHttpContextAccessor() ; + + // Overridable services. + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); } -} +} \ No newline at end of file