diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 53d75b8..4ea4eb4 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -90,7 +90,7 @@ { scope.ServiceProvider.GetRequiredService(); scope.ServiceProvider.GetRequiredService(); - scope.ServiceProvider.GetRequiredService>(); + scope.ServiceProvider.GetRequiredService(); var seeder = scope.ServiceProvider.GetService(); //if (seeder is not null) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 6919b0a..ebf7a93 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -31,8 +31,8 @@ Welcome to UltimateAuth! - - + + Login diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 80c99fe..6b5ce18 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -86,12 +86,12 @@ private async Task HandleGetMe() private async Task ChangeUserInactive() { - ChangeUserStatusRequest request = new ChangeUserStatusRequest + ChangeUserStatusAdminRequest request = new ChangeUserStatusAdminRequest { UserKey = UserKey.FromString("user"), NewStatus = UserStatus.Disabled }; - var result = await UAuth.Users.ChangeStatusAsync(request); + var result = await UAuth.Users.ChangeStatusAdminAsync(request); if (result.Ok) { Snackbar.Add($"User is disabled.", Severity.Info); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index cff2558..1d02220 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -2,8 +2,11 @@ using CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; using CodeBeam.UltimateAuth.Authorization.Reference.Extensions; using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.InMemory.Extensions; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; @@ -13,6 +16,7 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Sessions.InMemory; using CodeBeam.UltimateAuth.Tokens.InMemory; +using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference; using CodeBeam.UltimateAuth.Users.Reference.Extensions; @@ -96,17 +100,6 @@ var app = builder.Build(); -using (var scope = app.Services.CreateScope()) -{ - scope.ServiceProvider.GetRequiredService(); - //scope.ServiceProvider.GetRequiredService(); - //scope.ServiceProvider.GetRequiredService>(); - - var seeder = scope.ServiceProvider.GetService(); - //if (seeder is not null) - // await seeder.SeedAsync(); -} - // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -118,6 +111,14 @@ { app.MapOpenApi(); app.MapScalarApiReference(); + using var scope = app.Services.CreateScope(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService(); + //scope.ServiceProvider.GetRequiredService>(); + var seedRunner = scope.ServiceProvider.GetRequiredService(); + + await seedRunner.RunAsync(tenantId: null); } app.UseHttpsRedirection(); diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index 5ead5c5..928068a 100644 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -29,6 +29,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs index fab6aae..db0e642 100644 --- a/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Client/Extensions/UltimateAuthClientServiceCollectionExtensions.cs @@ -97,10 +97,12 @@ private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCol o.Refresh.Interval ??= TimeSpan.FromMinutes(5); }); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(sp => { diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs new file mode 100644 index 0000000..1985a95 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultAuthorizationClient.cs @@ -0,0 +1,65 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + internal sealed class DefaultAuthorizationClient : IAuthorizationClient + { + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public DefaultAuthorizationClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task> CheckAsync(AuthorizationCheckRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/check"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetMyRolesAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/authorization/users/me/roles/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetUserRolesAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson(raw); + } + + public async Task AssignRoleAsync(UserKey userKey, string role) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/post"); + var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + { + Role = role + }); + + return UAuthResultMapper.FromStatus(raw); + } + + public async Task RemoveRoleAsync(UserKey userKey, string role) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/authorization/users/{userKey}/roles/delete"); + + var raw = await _request.SendJsonAsync(url, new AssignRoleRequest + { + Role = role + }); + + return UAuthResultMapper.FromStatus(raw); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs new file mode 100644 index 0000000..0d00f49 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultCredentialClient.cs @@ -0,0 +1,103 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + internal sealed class DefaultUserCredentialClient : ICredentialClient + { + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public DefaultUserCredentialClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + private string Url(string path) => UAuthUrlBuilder.Combine(_options.Endpoints.Authority, path); + + public async Task> GetMyAsync() + { + var raw = await _request.SendFormForJsonAsync(Url("/credentials/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> AddMyAsync(AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url("/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/change"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/revoke"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/begin"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/credentials/{type}/reset/complete"), request); + return UAuthResultMapper.FromStatus(raw); + } + + + public async Task> GetUserAsync(UserKey userKey) + { + var raw = await _request.SendFormForJsonAsync(Url($"/admin/users/{userKey}/credentials/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/revoke"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task ActivateUserAsync(UserKey userKey, CredentialType type) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/activate")); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/begin"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey}/credentials/{type}/reset/complete"), request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteUserAsync(UserKey userKey, CredentialType type) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey}/credentials/{type}/delete")); + return UAuthResultMapper.FromStatus(raw); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs index 62818e8..9616a1b 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserClient.cs @@ -18,11 +18,11 @@ public DefaultUserClient(IUAuthRequestClient request, IOptions> GetMeAsync() + public async Task> GetMeAsync() { var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/get"); var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateMeAsync(UpdateProfileRequest request) @@ -39,9 +39,16 @@ public async Task> CreateAsync(CreateUserRequest r return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeStatusAsync(ChangeUserStatusRequest request) + public async Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request) { - var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/status"); + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/status"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{request.UserKey.Value}/status"); var raw = await _request.SendJsonAsync(url, request); return UAuthResultMapper.FromJson(raw); } @@ -53,11 +60,11 @@ public async Task> DeleteAsync(DeleteUserRequest r return UAuthResultMapper.FromJson(raw); } - public async Task> GetProfileAsync(UserKey userKey) + public async Task> GetProfileAsync(UserKey userKey) { var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/profile/get"); var raw = await _request.SendFormForJsonAsync(url); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request) diff --git a/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs new file mode 100644 index 0000000..50a0a6c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/DefaultUserIdentifierClient.cs @@ -0,0 +1,120 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + public class DefaultUserIdentifierClient : IUserIdentifierClient + { + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + + public DefaultUserIdentifierClient(IUAuthRequestClient request, IOptions options) + { + _request = request; + _options = options.Value; + } + + public async Task>> GetMyIdentifiersAsync() + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, "/users/me/identifiers/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddSelfAsync(AddUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/add"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UpdateSelfAsync(UpdateUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/set-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/unset-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task VerifySelfAsync(VerifyUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/verify"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteSelfAsync(DeleteUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/users/me/identifiers/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task>> GetUserIdentifiersAsync(UserKey userKey) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey.Value}/identifiers/get"); + var raw = await _request.SendFormForJsonAsync(url); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/add"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/update"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/set-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/unset-primary"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/verify"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + public async Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request) + { + var url = UAuthUrlBuilder.Combine(_options.Endpoints.Authority, $"/admin/users/{userKey}/identifiers/delete"); + var raw = await _request.SendJsonAsync(url, request); + return UAuthResultMapper.FromStatus(raw); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs new file mode 100644 index 0000000..7540005 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/IAuthorizationClient.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + public interface IAuthorizationClient + { + Task> CheckAsync(AuthorizationCheckRequest request); + + Task> GetMyRolesAsync(); + + Task> GetUserRolesAsync(UserKey userKey); + + Task AssignRoleAsync(UserKey userKey, string role); + + Task RemoveRoleAsync(UserKey userKey, string role); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs new file mode 100644 index 0000000..468dbcc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/ICredentialClient.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + public interface ICredentialClient + { + Task> GetMyAsync(); + Task> AddMyAsync(AddCredentialRequest request); + Task> ChangeMyAsync(CredentialType type, ChangeCredentialRequest request); + Task RevokeMyAsync(CredentialType type, RevokeCredentialRequest request); + Task BeginResetMyAsync(CredentialType type, BeginCredentialResetRequest request); + Task CompleteResetMyAsync(CredentialType type, CompleteCredentialResetRequest request); + + Task> GetUserAsync(UserKey userKey); + Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); + Task RevokeUserAsync(UserKey userKey, CredentialType type, RevokeCredentialRequest request); + Task ActivateUserAsync(UserKey userKey, CredentialType type); + Task BeginResetUserAsync(UserKey userKey, CredentialType type, BeginCredentialResetRequest request); + Task CompleteResetUserAsync(UserKey userKey, CredentialType type, CompleteCredentialResetRequest request); + Task DeleteUserAsync(UserKey userKey, CredentialType type); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs index e6bddb4..0a97c46 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUAuthClient.cs @@ -6,5 +6,8 @@ public interface IUAuthClient { IFlowClient Flows { get; } IUserClient Users { get; } + IUserIdentifierClient Identifiers { get; } + ICredentialClient Credentials { get; } + IAuthorizationClient Authorization { get; } } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs index 79d6bd9..a59a4cd 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserClient.cs @@ -7,13 +7,14 @@ namespace CodeBeam.UltimateAuth.Client.Services public interface IUserClient { Task> CreateAsync(CreateUserRequest request); - Task> ChangeStatusAsync(ChangeUserStatusRequest request); + Task> ChangeStatusSelfAsync(ChangeUserStatusSelfRequest request); + Task> ChangeStatusAdminAsync(ChangeUserStatusAdminRequest request); Task> DeleteAsync(DeleteUserRequest request); - Task> GetMeAsync(); + Task> GetMeAsync(); Task UpdateMeAsync(UpdateProfileRequest request); - Task> GetProfileAsync(UserKey userKey); + Task> GetProfileAsync(UserKey userKey); Task UpdateProfileAsync(UserKey userKey, UpdateProfileRequest request); } } diff --git a/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs new file mode 100644 index 0000000..5d40816 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Client/Services/IUserIdentifierClient.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services +{ + public interface IUserIdentifierClient + { + Task>> GetMyIdentifiersAsync(); + Task AddSelfAsync(AddUserIdentifierRequest request); + Task UpdateSelfAsync(UpdateUserIdentifierRequest request); + Task SetPrimarySelfAsync(SetPrimaryUserIdentifierRequest request); + Task UnsetPrimarySelfAsync(UnsetPrimaryUserIdentifierRequest request); + Task VerifySelfAsync(VerifyUserIdentifierRequest request); + Task DeleteSelfAsync(DeleteUserIdentifierRequest request); + + Task>> GetUserIdentifiersAsync(UserKey userKey); + Task AddAdminAsync(UserKey userKey, AddUserIdentifierRequest request); + Task UpdateAdminAsync(UserKey userKey, UpdateUserIdentifierRequest request); + Task SetPrimaryAdminAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); + Task UnsetPrimaryAdminAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request); + Task VerifyAdminAsync(UserKey userKey, VerifyUserIdentifierRequest request); + Task DeleteAdminAsync(UserKey userKey, DeleteUserIdentifierRequest request); + } +} diff --git a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs index 021de20..40d44f2 100644 --- a/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs +++ b/src/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -6,10 +6,16 @@ internal sealed class UAuthClient : IUAuthClient { public IFlowClient Flows { get; } public IUserClient Users { get; } + public IUserIdentifierClient Identifiers { get; } + public ICredentialClient Credentials { get; } + public IAuthorizationClient Authorization { get; } - public UAuthClient(IFlowClient flows, IUserClient users) + public UAuthClient(IFlowClient flows, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) { Flows = flows; Users = users; + Identifiers = identifiers; + Credentials = credentials; + Authorization = authorization; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs new file mode 100644 index 0000000..148ddc3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Contributes seed data for a specific domain (Users, Credentials, Authorization, etc). +/// Intended for dev/test/in-memory environments. +/// +public interface ISeedContributor +{ + /// + /// Execution order relative to other contributors. + /// Lower numbers run first. + /// + int Order { get; } + + Task SeedAsync(string? tenantId, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs new file mode 100644 index 0000000..ae7defd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Runtime, read-only user access for authentication flows. +/// Not a domain store. +/// +public interface IUserRuntimeStateProvider +{ + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index dd008ec..238390b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -23,6 +23,14 @@ public sealed class AccessContext public bool IsSelfAction => ActorUserKey != null && ResourceId != null && string.Equals(ActorUserKey.Value, ResourceId, StringComparison.Ordinal); public bool HasActor => ActorUserKey != null; public bool HasTarget => ResourceId != null; + + public UserKey GetTargetUserKey() + { + if (ResourceId is null) + throw new InvalidOperationException("Target user is not specified."); + + return UserKey.Parse(ResourceId, null); + } } internal sealed class EmptyAttributes : IReadOnlyDictionary diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs new file mode 100644 index 0000000..404b62b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class PagedResult + { + public IReadOnlyList Items { get; } + public int TotalCount { get; } + + public PagedResult(IReadOnlyList items, int totalCount) + { + Items = items; + TotalCount = totalCount; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs index e18593a..8d077b7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs @@ -21,7 +21,8 @@ public enum AuthFlowType PermissionQuery, UserManagement, - UserProfile, + UserProfileManagement, + UserIdentifierManagement, CredentialManagement, AuthorizationManagement, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs new file mode 100644 index 0000000..6e042d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed record UserRuntimeRecord +{ + public UserKey UserKey { get; init; } + public bool IsActive { get; init; } + public bool IsDeleted { get; init; } + public bool Exists { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs index 3d604bc..a8a7f03 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -86,6 +86,7 @@ private static IServiceCollection AddUltimateAuthInternal(this IServiceCollectio services.AddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs new file mode 100644 index 0000000..d0bf6ad --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class SeedRunner +{ + private readonly IEnumerable _contributors; + + public SeedRunner(IEnumerable contributors) + { + _contributors = contributors; + Console.WriteLine("SeedRunner contributors:"); + foreach (var c in contributors) + { + Console.WriteLine($"- {c.GetType().FullName}"); + } + } + + public async Task RunAsync(string? tenantId, CancellationToken ct = default) + { + foreach (var c in _contributors.OrderBy(x => x.Order)) + { + await c.SeedAsync(tenantId, ct); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs index 111ab05..1d0ce66 100644 --- a/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Defaults/UAuthActions.cs @@ -5,8 +5,9 @@ public static class UAuthActions public static class Users { public const string Create = "users.create"; - public const string Delete = "users.delete"; - public const string ChangeStatus = "users.status.change"; + public const string DeleteAdmin = "users.delete.admin"; + public const string ChangeStatusSelf = "users.status.change.self"; + public const string ChangeStatusAdmin = "users.status.change.admin"; } public static class UserProfiles @@ -19,29 +20,49 @@ public static class UserProfiles public static class UserIdentifiers { - public const string Get = "users.identifiers.get"; - public const string Change = "users.identifiers.change"; - public const string Verify = "users.identifiers.verify"; - public const string Delete = "users.identifiers.delete"; + public const string GetSelf = "users.identifiers.get.self"; + public const string GetAdmin = "users.identifiers.get.admin"; + public const string AddSelf = "users.identifiers.add.self"; + public const string AddAdmin = "users.identifiers.add.admin"; + public const string UpdateSelf = "users.identifiers.update.self"; + public const string UpdateAdmin = "users.identifiers.update.admin"; + public const string SetPrimarySelf = "users.identifiers.setprimary.self"; + public const string SetPrimaryAdmin = "users.identifiers.setprimary.admin"; + public const string UnsetPrimarySelf = "users.identifiers.unsetprimary.self"; + public const string UnsetPrimaryAdmin = "users.identifiers.unsetprimary.admin"; + public const string VerifySelf = "users.identifiers.verify.self"; + public const string VerifyAdmin = "users.identifiers.verify.admin"; + public const string DeleteSelf = "users.identifiers.delete.self"; + public const string DeleteAdmin = "users.identifiers.delete.admin"; } public static class Credentials { - public const string List = "credentials.list"; - public const string Add = "credentials.add"; - public const string Change = "credentials.change"; - public const string Revoke = "credentials.revoke"; - public const string Activate = "credentials.activate"; - public const string Delete = "credentials.delete"; + public const string ListSelf = "credentials.list.self"; + public const string ListAdmin = "credentials.list.admin"; + public const string AddSelf = "credentials.add.self"; + public const string AddAdmin = "credentials.add.admin"; + public const string ChangeSelf = "credentials.change.self"; + public const string ChangeAdmin = "credentials.change.admin"; + public const string RevokeSelf = "credentials.revoke.self"; + public const string RevokeAdmin = "credentials.revoke.admin"; + public const string ActivateSelf = "credentials.activate.self"; + public const string ActivateAdmin = "credentials.activate.admin"; + public const string BeginResetSelf = "credentials.beginreset.self"; + public const string BeginResetAdmin = "credentials.beginreset.admin"; + public const string CompleteResetSelf = "credentials.completereset.self"; + public const string CompleteResetAdmin = "credentials.completereset.admin"; + public const string DeleteAdmin = "credentials.delete.admin"; } public static class Authorization { public static class Roles { - public const string Read = "authorization.roles.read"; - public const string Assign = "authorization.roles.assign"; - public const string Remove = "authorization.roles.remove"; + public const string ReadSelf = "authorization.roles.read.self"; + public const string ReadAdmin = "authorization.roles.read.admin"; + public const string AssignAdmin = "authorization.roles.assign.admin"; + public const string RemoveAdmin = "authorization.roles.remove.admin"; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs index 53460a6..ff635f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -6,7 +6,8 @@ namespace CodeBeam.UltimateAuth.Server.Endpoints public interface IAuthorizationEndpointHandler { Task CheckAsync(HttpContext ctx); - Task GetRolesAsync(UserKey userKey, HttpContext ctx); + Task GetMyRolesAsync(HttpContext ctx); + Task GetUserRolesAsync(UserKey userKey, HttpContext ctx); Task AssignRoleAsync(UserKey userKey, HttpContext ctx); Task RemoveRoleAsync(UserKey userKey, HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs index 352e65e..a788a7b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -1,14 +1,22 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ICredentialEndpointHandler { - public interface ICredentialEndpointHandler - { - Task GetAllAsync(HttpContext ctx); - Task AddAsync(HttpContext ctx); - Task ChangeAsync(string type, HttpContext ctx); - Task RevokeAsync(string type, HttpContext ctx); - Task ActivateAsync(string type, HttpContext ctx); - Task DeleteAsync(string type, HttpContext ctx); - } + Task GetAllAsync(HttpContext ctx); + Task AddAsync(HttpContext ctx); + Task ChangeAsync(string type, HttpContext ctx); + Task RevokeAsync(string type, HttpContext ctx); + Task BeginResetAsync(string type, HttpContext ctx); + Task CompleteResetAsync(string type, HttpContext ctx); + + Task GetAllAdminAsync(UserKey userKey, HttpContext ctx); + Task AddAdminAsync(UserKey userKey, HttpContext ctx); + Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx); + Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs new file mode 100644 index 0000000..e543a95 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IUserEndpointHandler +{ + Task CreateAsync(HttpContext ctx); + Task ChangeStatusSelfAsync(HttpContext ctx); + Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteAsync(UserKey userKey, HttpContext ctx); + + Task GetMeAsync(HttpContext ctx); + Task UpdateMeAsync(HttpContext ctx); + + Task GetUserAsync(UserKey userKey, HttpContext ctx); + Task UpdateUserAsync(UserKey userKey, HttpContext ctx); + + Task GetMyIdentifiersAsync(HttpContext ctx); + Task AddUserIdentifierSelfAsync(HttpContext ctx); + Task UpdateUserIdentifierSelfAsync(HttpContext ctx); + Task SetPrimaryUserIdentifierSelfAsync(HttpContext ctx); + Task UnsetPrimaryUserIdentifierSelfAsync(HttpContext ctx); + Task VerifyUserIdentifierSelfAsync(HttpContext ctx); + Task DeleteUserIdentifierSelfAsync(HttpContext ctx); + + Task GetUserIdentifiersAsync(UserKey userKey, HttpContext ctx); + Task AddUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task UpdateUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task SetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task UnsetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task VerifyUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs deleted file mode 100644 index 08cdbe6..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserLifecycleEndpointHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface IUserLifecycleEndpointHandler - { - Task CreateAsync(HttpContext ctx); - Task ChangeStatusAsync(HttpContext ctx); - Task DeleteAsync(HttpContext ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs deleted file mode 100644 index 7717e26..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileAdminEndpointHandler.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface IUserProfileAdminEndpointHandler - { - Task GetAsync(UserKey userKey, HttpContext ctx); - Task UpdateAsync(UserKey userKey, HttpContext ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs deleted file mode 100644 index afc9e36..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserProfileEndpointHandler.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.Endpoints -{ - public interface IUserProfileEndpointHandler - { - Task GetAsync(HttpContext ctx); - Task UpdateAsync(HttpContext ctx); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index aed3688..2aa483c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using static CodeBeam.UltimateAuth.Server.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Server.Endpoints { @@ -92,94 +93,164 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options => await h.RevokeAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); } - if (options.EnableUserInfoEndpoints != false) - { - var user = group.MapGroup(""); + var user = group.MapGroup(""); + var users = group.MapGroup("/users"); + var adminUsers = group.MapGroup("/admin/users"); - user.MapPost("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - => await h.GetUserInfoAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); + //if (options.EnableUserInfoEndpoints != false) + //{ + // user.MapPost("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.GetUserInfoAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); - user.MapPost("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - => await h.GetPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); + // user.MapPost("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.GetPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); - user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) - => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); - } + // user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); + //} - if (options.EnableUserLifecycleEndpoints) + if (options.EnableUserLifecycleEndpoints != false) { - var users = group.MapGroup("/users"); - - users.MapPost("/create", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) + users.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); - users.MapPost("/status", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) - => await h.ChangeStatusAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + users.MapPost("/me/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); // Post is intended for Auth - users.MapPost("/delete", async ([FromServices] IUserLifecycleEndpointHandler h, HttpContext ctx) - => await h.DeleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); } - if (options.EnableUserProfileEndpoints) + if (options.EnableUserProfileEndpoints != false) { - var userProfile = group.MapGroup("/users"); + users.MapPost("/me/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - userProfile.MapPost("/me/get", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) - => await h.GetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfile)); + users.MapPost("/me/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); - userProfile.MapPost("/me/update", async ([FromServices] IUserProfileEndpointHandler h, HttpContext ctx) - => await h.UpdateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); + adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + + adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); } - if (options.EnableAdminChangeUserProfileEndpoints) + if (options.EnableUserIdentifierEndpoints != false) { - var admin = group.MapGroup("/admin/users"); + users.MapPost("/me/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + users.MapPost("/me/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + + adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - admin.MapPost("/{userKey}/profile/get", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); - admin.MapPost("/{userKey}/profile/update", async ([FromServices] IUserProfileAdminEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); } - if (options.EnableCredentialsEndpoints) + if (options.EnableCredentialsEndpoints != false) { var credentials = group.MapGroup("/credentials"); + var adminCredentials = group.MapGroup("/admin/users/{userKey}/credentials"); credentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.GetAllAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/post", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + credentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/update/{type}", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + credentials.MapPost("/{type}/change", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) => await h.ChangeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); credentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) => await h.RevokeAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.ActivateAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + credentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.BeginResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + credentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) + => await h.CompleteResetAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + + adminCredentials.MapPost("/get", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetAllAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); - credentials.MapPost("/delete/{type}", async ([FromServices] ICredentialEndpointHandler h, string type, HttpContext ctx) - => await h.DeleteAsync(type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + adminCredentials.MapPost("/{type}/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.RevokeAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/activate", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.ActivateAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.BeginResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.CompleteResetAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + adminCredentials.MapPost("/{type}/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, string type, HttpContext ctx) + => await h.DeleteAdminAsync(userKey, type, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); } - if (options.EnableAuthorizationEndpoints) + if (options.EnableAuthorizationEndpoints != false) { var authz = group.MapGroup("/authorization"); + var adminAuthz = group.MapGroup("/admin/authorization"); authz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + authz.MapPost("/users/me/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + + adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + adminAuthz.MapPost("/users/{userKey}/roles/post", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); - authz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + adminAuthz.MapPost("/users/{userKey}/roles/delete", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index 2dc154b..494af80 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -255,6 +255,36 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol return services; } + //internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) + //{ + // if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) + // throw new InvalidOperationException("UltimateAuth policies already registered."); + + // var registry = new AccessPolicyRegistry(); + + // DefaultPolicySet.Register(registry); + // configure?.Invoke(registry); + // services.AddSingleton(registry); + // services.AddSingleton(sp => + // { + // var compiled = registry.Build(); + // return new DefaultAccessPolicyProvider(compiled, sp); + // }); + + // services.TryAddScoped(sp => + // { + // var invariants = sp.GetServices(); + // var globalPolicies = sp.GetServices(); + + // return new DefaultAccessAuthority(invariants, globalPolicies); + // }); + + // services.TryAddScoped(); + + + // return services; + //} + internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null) { if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) @@ -264,28 +294,34 @@ internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollecti DefaultPolicySet.Register(registry); configure?.Invoke(registry); + + // 1. Registry (global, mutable until Build) services.AddSingleton(registry); - services.AddSingleton(sp => + + // 2. Compiled policy set (immutable, singleton) + services.AddSingleton(sp => { - var compiled = registry.Build(); - return new DefaultAccessPolicyProvider(compiled, sp); + var r = sp.GetRequiredService(); + return r.Build(); }); + // 3. Policy provider MUST be scoped + services.AddScoped(); + + // 4. Authority (scoped, correct) services.TryAddScoped(sp => { var invariants = sp.GetServices(); var globalPolicies = sp.GetServices(); - return new DefaultAccessAuthority(invariants, globalPolicies); }); + // 5. Orchestrator (scoped) services.TryAddScoped(); - return services; } - // ========================= // USERS (FRAMEWORK-REQUIRED) // ========================= diff --git a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs index 2df88ac..028f660 100644 --- a/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Login/DefaultLoginOrchestrator.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Abstractions; namespace CodeBeam.UltimateAuth.Server.Login.Orchestrators { @@ -15,7 +16,7 @@ internal sealed class DefaultLoginOrchestrator : ILoginOrchestrator _credentialStore; // authentication private readonly ICredentialValidator _credentialValidator; - private readonly IUserStore _users; // eligible + private readonly IUserRuntimeStateProvider _users; // eligible private readonly IUserSecurityStateProvider _userSecurityStateProvider; // runtime risk private readonly ILoginAuthority _authority; private readonly ISessionOrchestrator _sessionOrchestrator; @@ -26,7 +27,7 @@ internal sealed class DefaultLoginOrchestrator : ILoginOrchestrator credentialStore, ICredentialValidator credentialValidator, - IUserStore users, + IUserRuntimeStateProvider users, IUserSecurityStateProvider userSecurityStateProvider, ILoginAuthority authority, ISessionOrchestrator sessionOrchestrator, @@ -85,7 +86,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req userKey = UserKey.FromString(converter.ToString(validatedUserId)); } - var user = await _users.FindByIdAsync(request.TenantId, validatedUserId); + var user = userKey is not null + ? await _users.GetAsync(request.TenantId, userKey.Value, ct) + : null; if (user is null || user.IsDeleted || !user.IsActive) { diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 6e3da9d..ddf940d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -108,11 +108,11 @@ public void ReplaceSessionCookieManager() where T : class, IUAuthCookieManage public bool? EnableSessionEndpoints { get; set; } = true; public bool? EnableUserInfoEndpoints { get; set; } = true; - public bool EnableUserLifecycleEndpoints { get; set; } = true; - public bool EnableUserProfileEndpoints { get; set; } = true; - public bool EnableAdminChangeUserProfileEndpoints { get; set; } = false; - public bool EnableCredentialsEndpoints { get; set; } = true; - public bool EnableAuthorizationEndpoints { get; set; } = true; + public bool? EnableUserLifecycleEndpoints { get; set; } = true; + public bool? EnableUserProfileEndpoints { get; set; } = true; + public bool? EnableUserIdentifierEndpoints { get; set; } = true; + public bool? EnableCredentialsEndpoints { get; set; } = true; + public bool? EnableAuthorizationEndpoints { get; set; } = true; public UserIdentifierOptions UserIdentifiers { get; set; } = new(); @@ -176,7 +176,6 @@ internal UAuthServerOptions Clone() EnableUserInfoEndpoints = EnableUserInfoEndpoints, EnableUserLifecycleEndpoints = EnableUserLifecycleEndpoints, EnableUserProfileEndpoints = EnableUserProfileEndpoints, - EnableAdminChangeUserProfileEndpoints = EnableAdminChangeUserProfileEndpoints, EnableCredentialsEndpoints = EnableCredentialsEndpoints, EnableAuthorizationEndpoints = EnableAuthorizationEndpoints, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs index 3cbbf12..2e4bd92 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/AuthorizationInMemoryExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Authorization.InMemory.Extensions @@ -7,8 +8,10 @@ public static class AuthorizationInMemoryExtensions { public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) { - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddSingleton(); + + // Never try add - seeding is enumerated and all contributors are added. + services.AddSingleton(); return services; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs new file mode 100644 index 0000000..cd459d7 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeedContributor.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryAuthorizationSeedContributor : ISeedContributor +{ + public int Order => 20; + + private readonly IUserRoleStore _roles; + private readonly IInMemoryUserIdProvider _ids; + + public InMemoryAuthorizationSeedContributor(IUserRoleStore roles, IInMemoryUserIdProvider ids) + { + _roles = roles; + _ids = ids; + } + + public async Task SeedAsync(string? tenantId, CancellationToken ct = default) + { + var adminKey = _ids.GetAdminUserId(); + + await _roles.AssignAsync(tenantId, adminKey, "Admin", ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs deleted file mode 100644 index 82af6b3..0000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/InMemoryAuthorizationSeeder.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; - -namespace CodeBeam.UltimateAuth.Authorization.InMemory -{ - internal sealed class InMemoryAuthorizationSeeder : IAuthorizationSeeder - { - private readonly IUserRoleStore _roles; - private readonly IInMemoryUserIdProvider _ids; - - public InMemoryAuthorizationSeeder(IUserRoleStore roles, IInMemoryUserIdProvider ids) - { - _roles = roles; - _ids = ids; - } - - public async Task SeedAsync(CancellationToken ct = default) - { - var key = _ids.GetAdminUserId(); - await _roles.AssignAsync(null, key, "Admin", ct); - } - } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs index e10afdf..3f54673 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -1,50 +1,52 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; using System.Collections.Concurrent; -namespace CodeBeam.UltimateAuth.Authorization.InMemory +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryUserRoleStore : IUserRoleStore { - internal sealed class InMemoryUserRoleStore : IUserRoleStore + private readonly ConcurrentDictionary<(string? TenantId, UserKey UserKey), HashSet> _roles = new(); + + public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - private readonly ConcurrentDictionary> _roles = new(); + ct.ThrowIfCancellationRequested(); - public InMemoryUserRoleStore(IInMemoryUserIdProvider ids) + if (_roles.TryGetValue((tenantId, userKey), out var set)) { - var key = ids.GetAdminUserId(); - _roles[key] = new HashSet + lock (set) { - "Admin" - }; + return Task.FromResult>(set.ToArray()); + } } - public Task> GetRolesAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (_roles.TryGetValue(userKey, out var set)) - return Task.FromResult>(set.ToArray()); + return Task.FromResult>(Array.Empty()); + } - return Task.FromResult>(Array.Empty()); - } + public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - public Task AssignAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + var set = _roles.GetOrAdd((tenantId, userKey), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + lock (set) { - ct.ThrowIfCancellationRequested(); - - var set = _roles.GetOrAdd(userKey, _ => new HashSet(StringComparer.OrdinalIgnoreCase)); set.Add(role); - return Task.CompletedTask; } - public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } - if (_roles.TryGetValue(userKey, out var set)) - set.Remove(role); + public Task RemoveAsync(string? tenantId, UserKey userKey, string role, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; + if (_roles.TryGetValue((tenantId, userKey), out var set)) + { + lock (set) + { + set.Remove(role); + } } - } + return Task.CompletedTask; + } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs index 41c69b1..99f8258 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/DefaultAuthorizationEndpointHandler.cs @@ -48,7 +48,28 @@ public async Task CheckAsync(HttpContext ctx) : Results.Forbid(); } - public async Task GetRolesAsync(UserKey userKey, HttpContext ctx) + public async Task GetMyRolesAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.ReadSelf, + resource: "authorization.roles", + resourceId: flow.UserKey!.Value + ); + + var roles = await _roles.GetRolesAsync(accessContext, flow.UserKey!.Value, ctx.RequestAborted); + return Results.Ok(new UserRolesResponse + { + UserKey = flow.UserKey!.Value, + Roles = roles + }); + + } + + public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; if (!flow.IsAuthenticated) @@ -56,7 +77,7 @@ public async Task GetRolesAsync(UserKey userKey, HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.Read, + action: UAuthActions.Authorization.Roles.ReadAdmin, resource: "authorization.roles", resourceId: userKey.Value ); @@ -80,7 +101,7 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.Assign, + action: UAuthActions.Authorization.Roles.AssignAdmin, resource: "authorization.roles", resourceId: userKey.Value ); @@ -99,7 +120,7 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) var accessContext = await _accessContextFactory.CreateAsync( flow, - action: UAuthActions.Authorization.Roles.Remove, + action: UAuthActions.Authorization.Roles.RemoveAdmin, resource: "authorization.roles", resourceId: userKey.Value ); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs index 082e8f4..5e197c4 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -1,6 +1,4 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed class CredentialSecurityState { @@ -21,6 +19,8 @@ public CredentialSecurityState( Reason = reason; } + public static CredentialSecurityState Active { get; } = new(CredentialSecurityStatus.Active); + /// /// Determines whether the credential can be used at the given time. /// diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs index 1deeb5f..24838b7 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs @@ -6,5 +6,7 @@ public enum CredentialSecurityStatus Revoked = 10, Locked = 20, - Expired = 30 + Expired = 30, + ResetRequested = 40, + ResetRequired = 50 } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs new file mode 100644 index 0000000..bd6cd4b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginCredentialResetRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record BeginCredentialResetRequest + { + public string? Reason { get; init; } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs new file mode 100644 index 0000000..6afdea4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteCredentialResetRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Credentials.Contracts +{ + public sealed record CompleteCredentialResetRequest + { + public required string NewSecret { get; init; } + public string? Source { get; init; } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs new file mode 100644 index 0000000..468092e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs @@ -0,0 +1,45 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory +{ + internal sealed class InMemoryCredentialSeedContributor : ISeedContributor + { + public int Order => 10; + + private readonly ICredentialStore _credentials; + private readonly IInMemoryUserIdProvider _ids; + private readonly IUAuthPasswordHasher _hasher; + + public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher) + { + _credentials = credentials; + _ids = ids; + _hasher = hasher; + } + + public async Task SeedAsync(string? tenantId, CancellationToken ct = default) + { + await SeedCredentialAsync("admin", _ids.GetAdminUserId(), tenantId, ct); + await SeedCredentialAsync("user", _ids.GetUserUserId(), tenantId, ct); + } + + private async Task SeedCredentialAsync(string login, UserKey userKey, string? tenantId, CancellationToken ct) + { + if (await _credentials.ExistsAsync(tenantId, userKey, CredentialType.Password, ct)) + return; + + await _credentials.AddAsync(tenantId, + new PasswordCredential( + userKey, + login, + _hasher.Hash(login), + CredentialSecurityState.Active, + new CredentialMetadata(DateTimeOffset.Now, null, null)), + ct); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs index 413a15b..8bc957b 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs @@ -6,10 +6,12 @@ using CodeBeam.UltimateAuth.Credentials.Reference; using System.Collections.Concurrent; +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + internal sealed class InMemoryCredentialStore : ICredentialStore, ICredentialSecretStore where TUserId : notnull { - private readonly ConcurrentDictionary> _byLogin; - private readonly ConcurrentDictionary>> _byUser; + private readonly ConcurrentDictionary<(string? TenantId, string Login), InMemoryPasswordCredentialState> _byLogin; + private readonly ConcurrentDictionary<(string? TenantId, TUserId UserId), List>> _byUser; private readonly IUAuthPasswordHasher _hasher; private readonly IInMemoryUserIdProvider _userIdProvider; @@ -19,38 +21,15 @@ public InMemoryCredentialStore(IUAuthPasswordHasher hasher, IInMemoryUserIdProvi _hasher = hasher; _userIdProvider = userIdProvider; - _byLogin = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - _byUser = new ConcurrentDictionary>>(); - - SeedDefault(); - } - - private void SeedDefault() - { - SeedUser("admin", _userIdProvider.GetAdminUserId()); - SeedUser("user", _userIdProvider.GetUserUserId()); - } - - private void SeedUser(string login, TUserId userId) - { - var state = new InMemoryPasswordCredentialState - { - UserId = userId, - Login = login, - SecretHash = _hasher.Hash(login), - Security = new CredentialSecurityState(CredentialSecurityStatus.Active), - Metadata = new CredentialMetadata(DateTimeOffset.UtcNow, null, "seed") - }; - - _byLogin[login] = state; - _byUser[userId] = new List> { state }; + _byLogin = new ConcurrentDictionary<(string?, string), InMemoryPasswordCredentialState>(); + _byUser = new ConcurrentDictionary<(string?, TUserId), List>>(); } public Task>> FindByLoginAsync(string? tenantId, string loginIdentifier, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byLogin.TryGetValue(loginIdentifier, out var state)) + if (!_byLogin.TryGetValue((tenantId, loginIdentifier), out var state)) return Task.FromResult>>(Array.Empty>()); return Task.FromResult>>(new[] { Map(state) }); @@ -60,23 +39,22 @@ public Task>> GetByUserAsync(string? te { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue(userId, out var list)) + if (!_byUser.TryGetValue((tenantId, userId), out var list)) return Task.FromResult>>(Array.Empty>()); - return Task.FromResult>>(list.Select(Map).Cast>().ToArray()); + return Task.FromResult>>(list.Select(Map).ToArray()); } public Task>> GetByUserAndTypeAsync(string? tenantId, TUserId userId, CredentialType type, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_byUser.TryGetValue(userId, out var list)) + if (!_byUser.TryGetValue((tenantId, userId), out var list)) return Task.FromResult>>(Array.Empty>()); return Task.FromResult>>( list.Where(c => c.Type == type) .Select(Map) - .Cast>() .ToArray()); } @@ -84,7 +62,7 @@ public Task ExistsAsync(string? tenantId, TUserId userId, CredentialType t { ct.ThrowIfCancellationRequested(); - return Task.FromResult(_byUser.TryGetValue(userId, out var list) && list.Any(c => c.Type == type)); + return Task.FromResult(_byUser.TryGetValue((tenantId, userId), out var list) && list.Any(c => c.Type == type)); } public Task AddAsync(string? tenantId, ICredential credential, CancellationToken ct = default) @@ -92,7 +70,7 @@ public Task AddAsync(string? tenantId, ICredential credential, Cancella ct.ThrowIfCancellationRequested(); if (credential is not PasswordCredential pwd) - throw new NotSupportedException("Only password credential supported in-memory."); + throw new NotSupportedException("Only password credentials are supported in-memory."); var state = new InMemoryPasswordCredentialState { @@ -103,8 +81,10 @@ public Task AddAsync(string? tenantId, ICredential credential, Cancella Metadata = pwd.Metadata }; - _byLogin[pwd.LoginIdentifier] = state; - _byUser.AddOrUpdate(pwd.UserId, + _byLogin[(tenantId, pwd.LoginIdentifier)] = state; + + _byUser.AddOrUpdate( + (tenantId, pwd.UserId), _ => new List> { state }, (_, list) => { @@ -119,7 +99,7 @@ public Task UpdateSecurityStateAsync(string? tenantId, TUserId userId, Credentia { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -133,7 +113,7 @@ public Task UpdateMetadataAsync(string? tenantId, TUserId userId, CredentialType { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -147,7 +127,7 @@ public Task SetAsync(string? tenantId, TUserId userId, CredentialType type, stri { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) @@ -161,13 +141,13 @@ public Task DeleteAsync(string? tenantId, TUserId userId, CredentialType type, C { ct.ThrowIfCancellationRequested(); - if (_byUser.TryGetValue(userId, out var list)) + if (_byUser.TryGetValue((tenantId, userId), out var list)) { var state = list.FirstOrDefault(c => c.Type == type); if (state != null) { list.Remove(state); - _byLogin.TryRemove(state.Login, out _); + _byLogin.TryRemove((tenantId, state.Login), out _); } } @@ -178,10 +158,10 @@ public Task DeleteByUserAsync(string? tenantId, TUserId userId, CancellationToke { ct.ThrowIfCancellationRequested(); - if (_byUser.TryRemove(userId, out var list)) + if (_byUser.TryRemove((tenantId, userId), out var list)) { foreach (var credential in list) - _byLogin.TryRemove(credential.Login, out _); + _byLogin.TryRemove((tenantId, credential.Login), out _); } return Task.CompletedTask; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs index d8b541f..422d72a 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/UltimateAuthDefaultsInMemoryExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Credentials.InMemory.Extensions @@ -8,8 +9,11 @@ public static class UltimateAuthCredentialsInMemoryExtensions public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { services.TryAddScoped(typeof(InMemoryCredentialStore<>)); - services.TryAddScoped(typeof(ICredentialStore<>), typeof(InMemoryCredentialStore<>)); - services.TryAddScoped(typeof(ICredentialSecretStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddSingleton(typeof(ICredentialStore<>), typeof(InMemoryCredentialStore<>)); + services.TryAddSingleton(typeof(ICredentialSecretStore<>), typeof(InMemoryCredentialStore<>)); + + // Never try add seed + services.AddSingleton(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs index 5d3acf6..78526c9 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ActivateCredentialCommand.cs @@ -7,16 +7,12 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class ActivateCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public ActivateCredentialCommand(IEnumerable policies, Func> execute) + public ActivateCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs index 46c5d2d..60fa352 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/AddCredentialCommand.cs @@ -7,17 +7,13 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference { internal sealed class AddCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public AddCredentialCommand(IEnumerable policies, Func> execute) + public AddCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs new file mode 100644 index 0000000..e3e6fd7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/BeginCredentialResetCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class BeginCredentialResetCommand : IAccessCommand +{ + private readonly Func> _execute; + + public BeginCredentialResetCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs index 12a3463..14b476d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/ChangeCredentialCommand.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; + using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -7,16 +6,12 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class ChangeCredentialCommand: IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public ChangeCredentialCommand(IEnumerable policies, Func> execute) + public ChangeCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs new file mode 100644 index 0000000..7bf3a0f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/CompleteCredentialResetCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class CompleteCredentialResetCommand : IAccessCommand +{ + private readonly Func> _execute; + + public CompleteCredentialResetCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs index 625b6ff..ae290b3 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/DeleteCredentialCommand.cs @@ -1,22 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class DeleteCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public DeleteCredentialCommand(IEnumerable policies, Func> execute) + public DeleteCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs index 8083e12..7c3461c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/GetAllCredentialsCommand.cs @@ -1,23 +1,17 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference { internal sealed class GetAllCredentialsCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public GetAllCredentialsCommand(IEnumerable policies, Func> execute) + public GetAllCredentialsCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context)=> _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs index c99980e..70bf785 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/RevokeCredentialCommand.cs @@ -1,22 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference; internal sealed class RevokeCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func> _execute; - public RevokeCredentialCommand( IEnumerable policies, Func> execute) + public RevokeCredentialCommand(Func> execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs index f0976bc..113e914 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Commands/SetInitialCredentialCommand.cs @@ -1,22 +1,16 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Credentials.Reference { internal sealed class SetInitialCredentialCommand : IAccessCommand { - private readonly IEnumerable _policies; private readonly Func _execute; - public SetInitialCredentialCommand(IEnumerable policies, Func execute) + public SetInitialCredentialCommand(Func execute) { - _policies = policies ?? Array.Empty(); _execute = execute; } - public IEnumerable GetPolicies(AccessContext context) => _policies; - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs index b41074a..25ea1b9 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/DefaultCredentialEndpointHandler.cs @@ -1,161 +1,302 @@ -using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Defaults; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Extensions; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Credentials.Reference +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class DefaultCredentialEndpointHandler : ICredentialEndpointHandler { - public sealed class DefaultCredentialEndpointHandler : ICredentialEndpointHandler + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserCredentialsService _credentials; + + public DefaultCredentialEndpointHandler( + IAuthFlowContextAccessor authFlow, + IAccessContextFactory accessContextFactory, + IUserCredentialsService credentials) { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserCredentialsService _credentials; + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _credentials = credentials; + } - public DefaultCredentialEndpointHandler( - IAuthFlowContextAccessor authFlow, - IAccessContextFactory accessContextFactory, - IUserCredentialsService credentials) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _credentials = credentials; - } + public async Task GetAllAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - private bool TryGetAuthenticatedUser(out AuthFlowContext flow, out IResult? error) - { - flow = _authFlow.Current; + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ListSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - if (!flow.IsAuthenticated || flow.UserKey is null) - { - error = Results.Unauthorized(); - return false; - } + var result = await _credentials.GetAllAsync(accessContext, ctx.RequestAborted); + return Results.Ok(result); + } - error = null; - return true; - } + public async Task AddAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task GetAllAsync(HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.List, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.AddSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var result = await _credentials.GetAllAsync( - accessContext, - ctx.RequestAborted); + var result = await _credentials.AddAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } - return Results.Ok(result); - } + public async Task ChangeAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task AddAsync(HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + if (!TryParseType(type, out var credentialType, out error)) + return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Add, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ChangeSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var result = await _credentials.AddAsync( - accessContext, - request, - ctx.RequestAborted); + var result = await _credentials.ChangeAsync( + accessContext, credentialType, request, ctx.RequestAborted); - return Results.Ok(result); - } + return Results.Ok(result); + } - public async Task ChangeAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + public async Task RevokeAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + if (!TryParseType(type, out var credentialType, out error)) + return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Change, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.RevokeSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var result = await _credentials.ChangeAsync( - accessContext, - credentialType, - request, - ctx.RequestAborted); + await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } - return Results.Ok(result); - } + public async Task BeginResetAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task RevokeAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + if (!TryParseType(type, out var credentialType, out error)) + return error!; - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.BeginResetSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Revoke, - resource: "credentials", - resourceId: flow.UserKey.Value); + await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } - await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); - return Results.NoContent(); - } + public async Task CompleteResetAsync(string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; - public async Task ActivateAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + if (!TryParseType(type, out var credentialType, out error)) + return error!; - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Activate, - resource: "credentials", - resourceId: flow.UserKey.Value); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.CompleteResetSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); - await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); - return Results.NoContent(); - } + await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } - public async Task DeleteAsync(string type, HttpContext ctx) - { - if (!TryGetAuthenticatedUser(out var flow, out var error)) - return error!; + public async Task GetAllAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ListAdmin, + resource: "credentials", + resourceId: userKey.Value); + + var result = await _credentials.GetAllAsync(accessContext, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task AddAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.AddAdmin, + resource: "credentials", + resourceId: userKey.Value); + + var result = await _credentials.AddAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task RevokeAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); - if (!CredentialTypeParser.TryParse(type, out var credentialType)) - return Results.BadRequest($"Unsupported credential type: {type}"); + if (!TryParseType(type, out var credentialType, out var error)) + return error!; - var accessContext = await _accessContextFactory.CreateAsync( - flow, - action: UAuthActions.Credentials.Delete, - resource: "credentials", - resourceId: flow.UserKey.Value); + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); - return Results.NoContent(); + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.RevokeAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.RevokeAsync(accessContext, credentialType, request, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task ActivateAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (!TryParseType(type, out var credentialType, out var error)) + return error!; + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ActivateAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.ActivateAsync(accessContext, credentialType, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task DeleteAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (!TryParseType(type, out var credentialType, out var error)) + return error!; + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.DeleteAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.DeleteAsync(accessContext, credentialType, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task BeginResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + if (!TryParseType(type, out var credentialType, out error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.BeginResetAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.BeginResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } + + public async Task CompleteResetAdminAsync(UserKey userKey, string type, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + if (!TryParseType(type, out var credentialType, out error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.CompleteResetAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.CompleteResetAsync(accessContext, credentialType, request, ctx.RequestAborted); + return Results.NoContent(); + } + + private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) + { + flow = _authFlow.Current; + if (!flow.IsAuthenticated || flow.UserKey is null) + { + error = Results.Unauthorized(); + return false; } + + error = null; + return true; } + private static bool TryParseType(string type, out CredentialType credentialType, out IResult? error) + { + if (!CredentialTypeParser.TryParse(type, out credentialType)) + { + error = Results.BadRequest($"Unsupported credential type: {type}"); + return false; + } + + error = null; + return true; + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs index 083a5f7..0f191c3 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Users.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -12,6 +13,7 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs new file mode 100644 index 0000000..29084e7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -0,0 +1,47 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Users.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration +{ + private readonly ICredentialStore _credentialStore; + private readonly IUAuthPasswordHasher _passwordHasher; + private readonly IClock _clock; + + public PasswordUserLifecycleIntegration(ICredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock) + { + _credentialStore = credentialStore; + _passwordHasher = passwordHasher; + _clock = clock; + } + + public async Task OnUserCreatedAsync(string? tenantId, UserKey userKey, object request, CancellationToken ct) + { + if (request is not CreateUserRequest r) + return; + + if (string.IsNullOrWhiteSpace(r.Password)) + return; + + var hash = _passwordHasher.Hash(r.Password); + + var credential = new PasswordCredential( + userId: userKey, + loginIdentifier: r.PrimaryIdentifierValue!, + secretHash: hash, + security: new CredentialSecurityState(CredentialSecurityStatus.Active, null, null, null), + metadata: new CredentialMetadata(_clock.UtcNow, _clock.UtcNow, null)); + + await _credentialStore.AddAsync(tenantId, credential, ct); + } + + public async Task OnUserDeletedAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct) + { + await _credentialStore.DeleteByUserAsync(tenantId, userKey, ct); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs index 27eb76c..99f0b21 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/DefaultUserCredentialsService.cs @@ -33,7 +33,7 @@ public async Task GetAllAsync(AccessContext context, Cance { ct.ThrowIfCancellationRequested(); - var cmd = new GetAllCredentialsCommand(Array.Empty(), + var cmd = new GetAllCredentialsCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -65,7 +65,7 @@ public async Task AddAsync(AccessContext context, AddCreden { ct.ThrowIfCancellationRequested(); - var cmd = new AddCredentialCommand(Array.Empty(), + var cmd = new AddCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -102,7 +102,7 @@ public async Task ChangeAsync(AccessContext context, Cre { ct.ThrowIfCancellationRequested(); - var cmd = new ChangeCredentialCommand(Array.Empty(), + var cmd = new ChangeCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -123,7 +123,7 @@ public async Task RevokeAsync(AccessContext context, Cre { ct.ThrowIfCancellationRequested(); - var cmd = new RevokeCredentialCommand(Array.Empty(), + var cmd = new RevokeCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -146,7 +146,7 @@ public async Task ActivateAsync(AccessContext context, C { ct.ThrowIfCancellationRequested(); - var cmd = new ActivateCredentialCommand(Array.Empty(), + var cmd = new ActivateCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) @@ -161,11 +161,46 @@ public async Task ActivateAsync(AccessContext context, C return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } + public async Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct) + { + var cmd = new BeginCredentialResetCommand(async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var security = new CredentialSecurityState(CredentialSecurityStatus.ResetRequested, reason: request.Reason); + + await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + return CredentialActionResult.Success(); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct) + { + var cmd = new CompleteCredentialResetCommand(async innerCt => + { + if (context.ActorUserKey is not UserKey userKey) + throw new UnauthorizedAccessException(); + + var hash = _hasher.Hash(request.NewSecret); + + await _secrets.SetAsync(context.ResourceTenantId, userKey, type, hash, innerCt); + + var security = new CredentialSecurityState(CredentialSecurityStatus.Active); + await _credentials.UpdateSecurityStateAsync(context.ResourceTenantId, userKey, type, security, innerCt); + return CredentialActionResult.Success(); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + public async Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new DeleteCredentialCommand(Array.Empty(), + var cmd = new DeleteCredentialCommand( async innerCt => { if (context.ActorUserKey is not UserKey userKey) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs index b943d0a..eaddd57 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/IUserCredentialsService.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; namespace CodeBeam.UltimateAuth.Credentials.Reference; @@ -16,5 +15,9 @@ public interface IUserCredentialsService Task ActivateAsync(AccessContext context, CredentialType type, CancellationToken ct = default); + Task BeginResetAsync(AccessContext context, CredentialType type, BeginCredentialResetRequest request, CancellationToken ct = default); + + Task CompleteResetAsync(AccessContext context, CredentialType type, CompleteCredentialResetRequest request, CancellationToken ct = default); + Task DeleteAsync(AccessContext context, CredentialType type, CancellationToken ct = default); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs b/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs index 3110e44..1c13458 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs index 940e101..f63214b 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Policies.Registry; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Policies.Registry; +using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Policies.Defaults; @@ -6,20 +8,15 @@ internal static class DefaultPolicySet { public static void Register(AccessPolicyRegistry registry) { - // Everyone must be authenticated + // Invariant registry.Add("", _ => new RequireAuthenticatedPolicy()); - - // Self operations - registry.Add("users.profile.", _ => new RequireSelfPolicy()); - registry.Add("credentials.self.", _ => new RequireSelfPolicy()); - - // Admin-only - registry.Add("admin.", _ => new RequireAdminPolicy()); - - // Self OR admin - registry.Add("users.", _ => new RequireSelfOrAdminPolicy()); - - // Global safety registry.Add("", _ => new DenyCrossTenantPolicy()); + registry.Add("", sp => new RequireActiveUserPolicy(sp.GetRequiredService())); + + // Intent-based + registry.Add("", _ => new RequireSelfPolicy()); + registry.Add("", _ => new RequireAdminPolicy()); + registry.Add("", _ => new RequireSelfOrAdminPolicy()); + registry.Add("", _ => new RequireSystemPolicy()); } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs new file mode 100644 index 0000000..1fc8f4b --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs @@ -0,0 +1,46 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies +{ + internal sealed class RequireActiveUserPolicy : IAccessPolicy + { + private readonly IUserRuntimeStateProvider _runtime; + + public RequireActiveUserPolicy(IUserRuntimeStateProvider runtime) + { + _runtime = runtime; + } + + public AccessDecision Decide(AccessContext context) + { + if (context.ActorUserKey is null) + return AccessDecision.Deny("missing_actor"); + + var state = _runtime.GetAsync(context.ActorTenantId, context.ActorUserKey!.Value).GetAwaiter().GetResult(); + + if (state == null || !state.Exists || state.IsDeleted) + return AccessDecision.Deny("user_not_found"); + + return state.IsActive + ? AccessDecision.Allow() + : AccessDecision.Deny("user_not_active"); + } + + public bool AppliesTo(AccessContext context) + { + if (!context.IsAuthenticated || context.IsSystemActor) + return false; + + return !AllowedForInactive.Any(prefix => context.Action.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + private static readonly string[] AllowedForInactive = + { + "users.status.change.", + "credentials.password.reset.", + "login.", + "reauth." + }; + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs index d926a28..9770267 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAdminPolicy.cs @@ -21,5 +21,5 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("admin_required"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".admin"); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs index 3da53e9..52e75a0 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfOrAdminPolicy.cs @@ -23,5 +23,5 @@ public AccessDecision Decide(AccessContext context) return AccessDecision.Deny("self_or_admin_required"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) => !context.Action.EndsWith(".self") && !context.Action.EndsWith(".admin"); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs index a92ab2b..ae6a4dd 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -15,5 +15,5 @@ public AccessDecision Decide(AccessContext context) : AccessDecision.Deny("not_self"); } - public bool AppliesTo(AccessContext context) => true; + public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".self"); } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs new file mode 100644 index 0000000..0524b71 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies +{ + internal sealed class RequireSystemPolicy : IAccessPolicy + { + public AccessDecision Decide(AccessContext context) + => context.IsSystemActor + ? AccessDecision.Allow() + : AccessDecision.Deny("system_actor_required"); + + public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".system", StringComparison.Ordinal); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs index 77f1c19..f02ebfc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserStatus.cs @@ -2,28 +2,20 @@ { public enum UserStatus { - // Normal state + Active = 0, - // User initiated SelfSuspended = 10, - // Administrative actions Disabled = 20, Suspended = 30, - // Security / risk based Locked = 40, RiskHold = 50, - // Lifecycle PendingActivation = 60, PendingVerification = 70, - // Terminal (soft-delete) Deactivated = 80, - - // Soft // TODO: User domain already have IsDeleted, this may remove - Deleted = 90 } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs similarity index 62% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs index 45bf995..5ebd9a4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileDto.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserViewDto.cs @@ -1,24 +1,25 @@ namespace CodeBeam.UltimateAuth.Users.Contracts { - public sealed record UserProfileDto + public sealed record UserViewDto { public string UserKey { get; init; } = default!; public string? UserName { get; init; } - public string? Email { get; init; } + public string? PrimaryEmail { get; init; } + public string? PrimaryPhone { get; init; } public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } - - public string? Phone { get; init; } + public string? Bio { get; init; } + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } public bool EmailVerified { get; init; } public bool PhoneVerified { get; init; } - public UserStatus Status { get; init; } public DateTimeOffset? CreatedAt { get; init; } - public DateTimeOffset? LastLoginAt { get; init; } + //public DateTimeOffset? LastLoginAt { get; init; } public IReadOnlyDictionary? Metadata { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs new file mode 100644 index 0000000..39de26b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record AddUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; + public bool IsPrimary { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs similarity index 80% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs index 842a482..d88b519 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts { - public sealed class ChangeUserStatusRequest + public sealed class ChangeUserStatusAdminRequest { public required UserKey UserKey { get; init; } public required UserStatus NewStatus { get; init; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs new file mode 100644 index 0000000..aaa7ad0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public class ChangeUserStatusSelfRequest + { + public required UserStatus NewStatus { get; init; } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs index dc5fe86..ff720ce 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -2,35 +2,21 @@ public sealed record CreateUserRequest { - /// - /// Primary identifier (username, email, external id). - /// Interpretation is application-specific. - /// - public required string Identifier { get; init; } + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } - /// - /// Optional password. - /// If null, user may be invited or use external login. - /// public string? Password { get; init; } - public string? DisplayName { get; set; } + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Bio { get; init; } + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } - public string? TenantId { get; init; } - - /// - /// Initial user status. - /// Defaults to Active. - /// - public UserStatus InitialStatus { get; init; } = UserStatus.Active; - - /// - /// Optional initial profile data. - /// - public UserProfileInput? Profile { get; init; } - - /// - /// Optional custom metadata. - /// - public IReadOnlyDictionary? Metadata { get; init; } + public UserIdentifierType? PrimaryIdentifierType { get; init; } + public string? PrimaryIdentifierValue { get; init; } + public bool PrimaryIdentifierVerified { get; init; } = false; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs index 0ed5ec4..f24058c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs @@ -5,7 +5,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts { public sealed class DeleteUserRequest { - public required UserKey UserKey { get; init; } public DeleteMode Mode { get; init; } = DeleteMode.Soft; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs new file mode 100644 index 0000000..66802a5 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SerPrimaryUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record SetPrimaryUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs new file mode 100644 index 0000000..f17e69b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UnsetPrimaryUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string Value { get; init; } = default!; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs index 3503699..018aac8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs @@ -5,7 +5,13 @@ public sealed record UpdateProfileRequest public string? FirstName { get; init; } public string? LastName { get; init; } public string? DisplayName { get; init; } - public string? PhoneNumber { get; init; } + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Bio { get; init; } + + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } public IReadOnlyDictionary? Metadata { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs new file mode 100644 index 0000000..880d591 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts +{ + public sealed record UpdateUserIdentifierRequest + { + public UserIdentifierType Type { get; init; } + public string OldValue { get; init; } = default!; + public string NewValue { get; init; } = default!; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs index 5b642d1..30049f2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -3,6 +3,6 @@ public sealed record VerifyUserIdentifierRequest { public required UserIdentifierType Type { get; init; } - public required string Code { get; init; } + public required string Value { get; init; } } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs index a30ecc5..641d5c4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/UltimateAuthUsersInMemoryExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Extensions.DependencyInjection; @@ -10,14 +11,15 @@ public static class UltimateAuthUsersInMemoryExtensions { public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) { - services.TryAddScoped, InMemoryUserStore>(); services.TryAddScoped(typeof(IUserSecurityStateProvider<>), typeof(InMemoryUserSecurityStateProvider<>)); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton, InMemoryUserIdProvider>(); + // Seed never try add + services.AddSingleton(); + return services; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs new file mode 100644 index 0000000..4a04f93 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Infrastructure/InMemoryUserSeedContributor.cs @@ -0,0 +1,73 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.InMemory +{ + internal sealed class InMemoryUserSeedContributor : ISeedContributor + { + public int Order => 0; + + private readonly IUserLifecycleStore _lifecycle; + private readonly IUserProfileStore _profiles; + private readonly IUserIdentifierStore _identifiers; + private readonly IInMemoryUserIdProvider _ids; + private readonly IClock _clock; + + public InMemoryUserSeedContributor( + IUserLifecycleStore lifecycle, + IUserProfileStore profiles, + IUserIdentifierStore identifiers, + IInMemoryUserIdProvider ids, + IClock clock) + { + _lifecycle = lifecycle; + _profiles = profiles; + _ids = ids; + _identifiers = identifiers; + _clock = clock; + } + + public async Task SeedAsync(string? tenantId, CancellationToken ct = default) + { + await SeedUserAsync(tenantId, _ids.GetAdminUserId(), "Administrator", "admin", ct); + await SeedUserAsync(tenantId, _ids.GetUserUserId(), "User", "user", ct); + } + + private async Task SeedUserAsync(string? tenantId, UserKey userKey, string displayName, string username, CancellationToken ct) + { + if (await _lifecycle.ExistsAsync(tenantId, userKey, ct)) + return; + + await _lifecycle.CreateAsync(tenantId, + new UserLifecycle + { + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = _clock.UtcNow + }, ct); + + await _profiles.CreateAsync(tenantId, + new UserProfile + { + UserKey = userKey, + DisplayName = displayName, + CreatedAt = _clock.UtcNow + }, ct); + + await _identifiers.CreateAsync(tenantId, + new UserIdentifier + { + UserKey = userKey, + Type = UserIdentifierType.Username, + Value = username, + IsPrimary = true, + IsVerified = true, + CreatedAt = _clock.UtcNow + }, ct); + } + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index f0ba8dd..ec9f36b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -2,132 +2,180 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Users.InMemory { - internal sealed class InMemoryUserIdentifierStore : IUserIdentifierStore + public sealed class InMemoryUserIdentifierStore : IUserIdentifierStore { - private readonly ConcurrentDictionary<(string? TenantId, UserKey UserKey), List> _byUser = new(); - private readonly ConcurrentDictionary<(string? TenantId, UserIdentifierType Type, string Value), UserKey> _lookup = new(); + private readonly Dictionary<(string? TenantId, UserIdentifierType Type, string Value), UserIdentifier> _store = new(); - public Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + return Task.FromResult(_store.TryGetValue((tenantId, type, value), out var id) && !id.IsDeleted); + } - var key = (tenantId, userKey); + public Task GetAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) + { + if (!_store.TryGetValue((tenantId, type, value), out var id)) + return Task.FromResult(null); - if (_byUser.TryGetValue(key, out var list)) - return Task.FromResult>(list.ToArray()); + if (id.IsDeleted) + return Task.FromResult(null); - return Task.FromResult>(Array.Empty()); + return Task.FromResult(id); } - public Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default) + public Task> GetByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var result = _store.Values + .Where(x => x.TenantId == tenantId) + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted) + .OrderByDescending(x => x.IsPrimary) + .ThenBy(x => x.CreatedAt) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } - var key = (tenantId, userKey); + public Task CreateAsync(string? tenantId, UserIdentifier identifier, CancellationToken ct = default) + { + var key = (tenantId, identifier.Type, identifier.Value); - if (_byUser.TryGetValue(key, out var list)) - { - var result = list.Where(x => x.Type == type).ToArray(); - return Task.FromResult>(result); - } + if (_store.TryGetValue(key, out var existing) && !existing.IsDeleted) + throw new InvalidOperationException("Identifier already exists."); - return Task.FromResult>(Array.Empty()); + _store[key] = identifier; + return Task.CompletedTask; } - public Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default) + public Task UpdateValueAsync(string? tenantId, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var userKeyTuple = (tenantId, userKey); - var lookupKey = (tenantId, record.Type, record.Value); + if (string.Equals(oldValue, newValue, StringComparison.Ordinal)) + throw new InvalidOperationException("identifier_value_unchanged"); - var list = _byUser.GetOrAdd(userKeyTuple, _ => new List()); + var oldKey = (tenantId, type, oldValue); - // replace if same type+value exists - var existingIndex = list.FindIndex(x => x.Type == record.Type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, record.Value)); + if (!_store.TryGetValue(oldKey, out var identifier) || identifier.IsDeleted) + throw new InvalidOperationException("identifier_not_found"); - if (existingIndex >= 0) - { - list[existingIndex] = record; - } - else - { - list.Add(record); - _lookup[lookupKey] = userKey; - } + var newKey = (tenantId, type, newValue); + + if (_store.ContainsKey(newKey)) + throw new InvalidOperationException("identifier_value_already_exists"); + + _store.Remove(oldKey); + + identifier.Value = newValue; + identifier.IsVerified = false; + identifier.VerifiedAt = null; + identifier.UpdatedAt = updatedAt; + + _store[newKey] = identifier; return Task.CompletedTask; } - public Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default) + public Task MarkVerifiedAsync(string? tenantId, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, type, value); - var key = (tenantId, userKey); + if (!_store.TryGetValue(key, out var id) || id.IsDeleted) + throw new InvalidOperationException("Identifier not found."); - if (!_byUser.TryGetValue(key, out var list)) + if (id.IsVerified) return Task.CompletedTask; - for (int i = 0; i < list.Count; i++) - { - if (list[i].Type == type && !list[i].VerifiedAt.HasValue) - { - list[i] = list[i] with - { - VerifiedAt = verifiedAt - }; - } - } + id.IsVerified = true; + id.VerifiedAt = verifiedAt; return Task.CompletedTask; } - public Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default) + public Task SetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + foreach (var id in _store.Values.Where(x => + x.TenantId == tenantId && + x.UserKey == userKey && + x.Type == type && + !x.IsDeleted && + x.IsPrimary)) + { + id.IsPrimary = false; + } var key = (tenantId, type, value); - return Task.FromResult(_lookup.ContainsKey(key)); + + if (!_store.TryGetValue(key, out var target) || target.IsDeleted) + throw new InvalidOperationException("Identifier not found."); + + target.IsPrimary = true; + return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default) + public Task UnsetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, type, value); + + if (!_store.TryGetValue(key, out var target) || target.IsDeleted) + throw new InvalidOperationException("Identifier not found."); + + target.IsPrimary = false; + return Task.CompletedTask; + } - var userKeyTuple = (tenantId, userKey); - var lookupKey = (tenantId, type, value); + public Task DeleteAsync(string? tenantId, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + var key = (tenantId, type, value); - if (!_byUser.TryGetValue(userKeyTuple, out var list)) + if (!_store.TryGetValue(key, out var id)) return Task.CompletedTask; - var index = list.FindIndex(x => x.Type == type && StringComparer.OrdinalIgnoreCase.Equals(x.Value, value)); + if (mode == DeleteMode.Hard) + { + _store.Remove(key); + return Task.CompletedTask; + } - if (index < 0) + if (id.IsDeleted) return Task.CompletedTask; - var record = list[index]; + id.IsDeleted = true; + id.DeletedAt = deletedAt; + id.IsPrimary = false; - if (mode == DeleteMode.Soft) - { - if (record.DeletedAt.HasValue) - return Task.CompletedTask; + return Task.CompletedTask; + } - list[index] = record with - { - DeletedAt = DateTimeOffset.UtcNow - }; - } - else + public Task DeleteByUserAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + var identifiers = _store.Values + .Where(x => x.TenantId == tenantId) + .Where(x => x.UserKey == userKey) + .ToList(); + + foreach (var id in identifiers) { - list.RemoveAt(index); - _lookup.TryRemove(lookupKey, out _); + if (mode == DeleteMode.Hard) + { + _store.Remove((tenantId, id.Type, id.Value)); + } + else + { + if (id.IsDeleted) + continue; + + id.IsDeleted = true; + id.DeletedAt = deletedAt; + id.IsPrimary = false; + } } return Task.CompletedTask; } + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 6038c12..0302cc2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,150 +1,110 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using CodeBeam.UltimateAuth.Users.Reference.Domain; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Users.InMemory; public sealed class InMemoryUserLifecycleStore : IUserLifecycleStore { - private readonly ConcurrentDictionary _users = new(); - private readonly IInMemoryUserIdProvider _idProvider; - private readonly IClock _clock; + private readonly Dictionary<(string?, UserKey), UserLifecycle> _store = new(); - public InMemoryUserLifecycleStore(IInMemoryUserIdProvider idProvider, IClock clock) + public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - _idProvider = idProvider; - _clock = clock; - SeedDefault(); + return Task.FromResult(_store.TryGetValue((tenantId, userKey), out var entity) && !entity.IsDeleted); } - private void SeedDefault() + public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - CreateSeedUser(_idProvider.GetAdminUserId(), "admin"); - CreateSeedUser(_idProvider.GetUserUserId(), "user"); - } + if (!_store.TryGetValue((tenantId, userKey), out var entity)) + return Task.FromResult(null); - private void CreateSeedUser(UserKey userKey, string identifier) - { - var now = _clock.UtcNow; + if (entity.IsDeleted) + return Task.FromResult(null); - var profile = new ReferenceUserProfile - { - UserKey = userKey, - Email = identifier, - DisplayName = identifier == "admin" - ? "Administrator" - : "Standard User", - Status = UserStatus.Active, - IsDeleted = false, - CreatedAt = now, - UpdatedAt = now, - DeletedAt = null - }; - - _users.TryAdd( - new UserIdentity(null, userKey), - profile); + return Task.FromResult(entity); } - public Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default) + public Task> QueryAsync(string? tenantId, UserLifecycleQuery query, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var baseQuery = _store.Values + .Where(x => x?.UserKey != null) + .Where(x => x.TenantId == tenantId); - ArgumentNullException.ThrowIfNull(user); + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); - var identity = new UserIdentity(tenantId, user.UserKey); + if (query.Status != null) + baseQuery = baseQuery.Where(x => x.Status == query.Status); - if (!_users.TryAdd(identity, InitializeUser(user))) - { - throw new InvalidOperationException($"User '{user.UserKey}' already exists in tenant '{tenantId ?? ""}'."); - } + var totalCount = baseQuery.Count(); - return Task.CompletedTask; + var items = baseQuery + .OrderBy(x => x.CreatedAt) + .Skip(query.Skip) + .Take(query.Take) + .ToList() + .AsReadOnly(); + + return Task.FromResult(new PagedResult(items, totalCount)); } - public Task UpdateStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default) + public Task CreateAsync(string? tenantId, UserLifecycle lifecycle, CancellationToken ct = default) { - var identity = new UserIdentity(tenantId, userKey); - - if (!_users.TryGetValue(identity, out var user)) - throw new InvalidOperationException($"User '{userKey}' does not exist."); + var key = (tenantId, lifecycle.UserKey); - if (user.IsDeleted) - throw new InvalidOperationException($"User '{userKey}' is deleted."); + if (_store.ContainsKey(key)) + throw new InvalidOperationException("UserLifecycle already exists."); - if (user.Status == status) - return Task.CompletedTask; + _store[key] = lifecycle; + return Task.CompletedTask; + } - if (!IsValidStatusTransition(user.Status, status)) - throw new InvalidOperationException($"Invalid status transition from '{user.Status}' to '{status}'."); + public Task ChangeStatusAsync(string? tenantId, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default) + { + if (!_store.TryGetValue((tenantId, userKey), out var entity) || entity.IsDeleted) + throw new InvalidOperationException("UserLifecycle not found."); - user.Status = status; - user.UpdatedAt = DateTimeOffset.UtcNow; + entity.Status = newStatus; + entity.UpdatedAt = updatedAt; return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, CancellationToken ct = default) + public Task ChangeSecurityStampAsync(string? tenantId, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + if (!_store.TryGetValue((tenantId, userKey), out var entity) || entity.IsDeleted) + throw new InvalidOperationException("UserLifecycle not found."); - var identity = new UserIdentity(tenantId, userKey); - - if (!_users.TryGetValue(identity, out var user)) - { + if (entity.SecurityStamp == newSecurityStamp) return Task.CompletedTask; - } - switch (mode) - { - case DeleteMode.Soft: - if (user.IsDeleted) - return Task.CompletedTask; - - user.Status = UserStatus.Deleted; - user.IsDeleted = true; - user.DeletedAt = at; - user.UpdatedAt = at; - break; - - case DeleteMode.Hard: - _users.TryRemove(identity, out _); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown delete mode."); - } + entity.SecurityStamp = newSecurityStamp; + entity.UpdatedAt = updatedAt; return Task.CompletedTask; } - private static ReferenceUserProfile InitializeUser(ReferenceUserProfile user) + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - return user with - { - Status = user.Status == default ? UserStatus.Active : user.Status, - CreatedAt = user.CreatedAt == default ? DateTimeOffset.UtcNow : user.CreatedAt, - UpdatedAt = DateTimeOffset.UtcNow, - IsDeleted = false, - DeletedAt = null - }; - } + var key = (tenantId, userKey); - private static bool IsValidStatusTransition(UserStatus from, UserStatus to) - { - return from switch + if (!_store.TryGetValue(key, out var entity)) + return Task.CompletedTask; + + if (mode == DeleteMode.Hard) { - UserStatus.Active => to is UserStatus.Suspended or UserStatus.Disabled, - UserStatus.Suspended => to is UserStatus.Active or UserStatus.Disabled, - UserStatus.Disabled => to is UserStatus.Active, - _ => false - }; - } + _store.Remove(key); + return Task.CompletedTask; + } + + // Soft delete (idempotent) + if (entity.IsDeleted) + return Task.CompletedTask; - private readonly record struct UserIdentity(string? TenantId, UserKey UserKey); + entity.IsDeleted = true; + entity.DeletedAt = deletedAt; + + return Task.CompletedTask; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 61008cf..162f110 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,131 +1,102 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; -using CodeBeam.UltimateAuth.Users.Reference.Domain; -using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Users.InMemory; -internal sealed class InMemoryUserProfileStore : IUserProfileStore +public sealed class InMemoryUserProfileStore : IUserProfileStore { - private readonly ConcurrentDictionary _profiles = new(); - private readonly IInMemoryUserIdProvider _idProvider; - private readonly IClock _clock; + private readonly Dictionary<(string? TenantId, UserKey UserKey), UserProfile> _store = new(); - internal IEnumerable AllProfiles => _profiles.Values; - - public InMemoryUserProfileStore(IInMemoryUserIdProvider idProvider, IClock clock) + public Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - _idProvider = idProvider; - _clock = clock; - SeedDefault(); + return Task.FromResult(_store.TryGetValue((tenantId, userKey), out var profile) && profile.DeletedAt == null); } - private void SeedDefault() + public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) { - SeedProfile(_idProvider.GetAdminUserId(), "Administrator"); - SeedProfile(_idProvider.GetUserUserId(), "Standard User"); - } + if (!_store.TryGetValue((tenantId, userKey), out var profile)) + return Task.FromResult(null); - private void SeedProfile(UserKey userKey, string displayName) - { - var now = _clock.UtcNow; + if (profile.DeletedAt != null) + return Task.FromResult(null); - _profiles[userKey] = new ReferenceUserProfile - { - UserKey = userKey, - DisplayName = displayName, - Status = UserStatus.Active, - CreatedAt = now, - UpdatedAt = now - }; + return Task.FromResult(profile); } - public Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default) + public Task> QueryAsync(string? tenantId, UserProfileQuery query, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var baseQuery = _store.Values + .Where(x => x.TenantId == tenantId); - var mem = new ReferenceUserProfile - { - UserKey = profile.UserKey, - FirstName = profile.FirstName, - LastName = profile.LastName, - DisplayName = profile.DisplayName, - Email = profile.Email, - Status = profile.Status, - IsDeleted = profile.IsDeleted, - CreatedAt = profile.CreatedAt, - UpdatedAt = profile.UpdatedAt, - DeletedAt = profile.DeletedAt - }; - - if (!_profiles.TryAdd(profile.UserKey, mem)) - throw new InvalidOperationException($"User profile '{profile.UserKey}' already exists."); + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); - return Task.CompletedTask; + var totalCount = baseQuery.Count(); + + var items = baseQuery + .OrderBy(x => x.CreatedAt) + .Skip(query.Skip) + .Take(query.Take) + .ToList() + .AsReadOnly(); + + return Task.FromResult(new PagedResult(items, totalCount)); } - public Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + public Task CreateAsync(string? tenantId, UserProfile profile, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, profile.UserKey); - if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) - return Task.FromResult(null); + if (_store.ContainsKey(key)) + throw new InvalidOperationException("UserProfile already exists."); - return Task.FromResult(Map(profile)); + _store[key] = profile; + return Task.CompletedTask; } - public Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default) + public Task UpdateAsync(string? tenantId, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, userKey); + + if (!_store.TryGetValue(key, out var existing) || existing.DeletedAt != null) + throw new InvalidOperationException("UserProfile not found."); - if (!_profiles.TryGetValue(userKey, out var profile) || profile.IsDeleted) - throw new InvalidOperationException("User profile does not exist."); + existing.FirstName = update.FirstName; + existing.LastName = update.LastName; + existing.DisplayName = update.DisplayName; + existing.BirthDate = update.BirthDate; + existing.Gender = update.Gender; + existing.Bio = update.Bio; + existing.Language = update.Language; + existing.TimeZone = update.TimeZone; + existing.Culture = update.Culture; + existing.Metadata = update.Metadata; - profile.FirstName = request.FirstName; - profile.LastName = request.LastName; - profile.DisplayName = request.DisplayName; - profile.UpdatedAt = DateTimeOffset.UtcNow; + existing.UpdatedAt = updatedAt; return Task.CompletedTask; } - public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default) + public Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) { - ct.ThrowIfCancellationRequested(); + var key = (tenantId, userKey); + + if (!_store.TryGetValue(key, out var profile)) + return Task.CompletedTask; if (mode == DeleteMode.Hard) { - _profiles.TryRemove(userKey, out _); + _store.Remove(key); return Task.CompletedTask; } - if (!_profiles.TryGetValue(userKey, out var profile)) - throw new InvalidOperationException("User profile does not exist."); + if (profile.IsDeleted) + return Task.CompletedTask; profile.IsDeleted = true; - profile.Status = UserStatus.Deleted; - profile.DeletedAt = DateTimeOffset.UtcNow; - profile.UpdatedAt = profile.DeletedAt; + profile.DeletedAt = deletedAt; return Task.CompletedTask; } - - private static ReferenceUserProfile Map(ReferenceUserProfile profile) - => new() - { - UserKey = profile.UserKey, - FirstName = profile.FirstName, - LastName = profile.LastName, - DisplayName = profile.DisplayName, - Email = profile.Email, - Status = profile.Status, - CreatedAt = profile.CreatedAt, - UpdatedAt = profile.UpdatedAt, - IsDeleted = profile.IsDeleted, - DeletedAt = profile.DeletedAt - }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs deleted file mode 100644 index 32a46ae..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserStore.cs +++ /dev/null @@ -1,86 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference; -using CodeBeam.UltimateAuth.Users.Reference.Domain; - -namespace CodeBeam.UltimateAuth.Users.InMemory; - -internal sealed class InMemoryUserStore : IUserStore -{ - private readonly InMemoryUserLifecycleStore _lifecycle; - private readonly IUserProfileStore _profiles; - - public InMemoryUserStore( - InMemoryUserLifecycleStore lifecycle, - IUserProfileStore profiles) - { - _lifecycle = lifecycle; - _profiles = profiles; - } - - public async Task?> FindByIdAsync( - string? tenantId, - UserKey userId, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - throw new NotImplementedException(); - //var lifecycle = await _lifecycle.GetAsync(tenantId, userId, ct); - //if (lifecycle is null || lifecycle.IsDeleted) - // return null; - - //var profile = await _profiles.GetAsync(tenantId, userId, ct); - - //return new AuthUserRecord - //{ - // Id = userId, - // Identifier = - // profile?.Email ?? - // profile?.DisplayName ?? - // userId.ToString(), - - // IsActive = lifecycle.Status == UserStatus.Active, - // IsDeleted = lifecycle.IsDeleted, - // CreatedAt = lifecycle.CreatedAt, - // DeletedAt = lifecycle.DeletedAt - //}; - } - - public async Task?> FindByLoginAsync( - string? tenantId, - string login, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - // InMemory limitation: scan profiles - if (_profiles is not InMemoryUserProfileStore mem) - throw new InvalidOperationException("InMemory only"); - - var profile = mem.AllProfiles - .FirstOrDefault(p => - !p.IsDeleted && - !string.IsNullOrWhiteSpace(p.Email) && - string.Equals(p.Email, login, StringComparison.OrdinalIgnoreCase)); - - if (profile is null) - return null; - - return await FindByIdAsync(tenantId, profile.UserKey, ct); - } - - public async Task ExistsAsync( - string? tenantId, - UserKey userId, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - throw new NotImplementedException(); - - //var lifecycle = await _lifecycle.GetAsync(tenantId, userId, ct); - //return lifecycle is not null && !lifecycle.IsDeleted; - } -} - diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs new file mode 100644 index 0000000..c35ef39 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/AddUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class AddUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public AddUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs index 85720a7..b29f57d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/CreateUserCommand.cs @@ -1,18 +1,19 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference { - internal sealed class CreateUserCommand : IAccessCommand + internal sealed class CreateUserCommand : IAccessCommand { - private readonly Func _execute; + private readonly Func> _execute; - public CreateUserCommand(Func execute) + public CreateUserCommand(Func> execute) { _execute = execute; } - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs deleted file mode 100644 index 068a1cd..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetCurrentUserProfileCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetCurrentUserProfileCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetCurrentUserProfileCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs new file mode 100644 index 0000000..813db3d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetMeCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class GetMeCommand : IAccessCommand +{ + private readonly Func> _execute; + + public GetMeCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs new file mode 100644 index 0000000..8da5b64 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifierCommand.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class GetUserIdentifierCommand : IAccessCommand + { + private readonly Func> _execute; + + public GetUserIdentifierCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs index ddca8f7..b931025 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserIdentifiersCommand.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Users.Reference { - internal sealed class GetUserIdentifiersCommand : IAccessCommand + internal sealed class GetUserIdentifiersCommand : IAccessCommand> { - private readonly Func> _execute; + private readonly Func>> _execute; - public GetUserIdentifiersCommand(Func> execute) + public GetUserIdentifiersCommand(Func>> execute) { _execute = execute; } - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + public Task> ExecuteAsync(CancellationToken ct = default) => _execute(ct); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs deleted file mode 100644 index 44d4bd4..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileAdminCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class GetUserProfileAdminCommand : IAccessCommand -{ - private readonly Func> _execute; - - public GetUserProfileAdminCommand(Func> execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs new file mode 100644 index 0000000..4912a6b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/GetUserProfileCommand.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class GetUserProfileCommand : IAccessCommand +{ + private readonly Func> _execute; + + public GetUserProfileCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs new file mode 100644 index 0000000..800d6c0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/SetPrimaryUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class SetPrimaryUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public SetPrimaryUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs new file mode 100644 index 0000000..f7f21b7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UnsetPrimaryUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class UnsetPrimaryUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public UnsetPrimaryUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs deleted file mode 100644 index 12b6cb8..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateCurrentUserProfileCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class UpdateCurrentUserProfileCommand : IAccessCommand -{ - private readonly Func _execute; - - public UpdateCurrentUserProfileCommand(Func execute) - { - _execute = execute; - } - - public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs new file mode 100644 index 0000000..d2521fa --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserIdentifierCommand.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class UpdateUserIdentifierCommand : IAccessCommand + { + private readonly Func _execute; + + public UpdateUserIdentifierCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs new file mode 100644 index 0000000..b102c86 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UpdateUserProfileCommand.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UpdateUserProfileCommand : IAccessCommand +{ + private readonly Func _execute; + + public UpdateUserProfileCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs new file mode 100644 index 0000000..7cd9335 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Commands/UserIdentifierExistsCommand.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Server.Infrastructure; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeBeam.UltimateAuth.Users.Reference.Commands +{ + internal sealed class UserIdentifierExistsCommand : IAccessCommand + { + private readonly Func> _execute; + + public UserIdentifierExistsCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs new file mode 100644 index 0000000..980c39f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public sealed class UserLifecycleQuery + { + public bool IncludeDeleted { get; init; } + public UserStatus? Status { get; init; } + + public int Skip { get; init; } + public int Take { get; init; } = 50; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs new file mode 100644 index 0000000..9bc8a3a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public sealed class UserProfileQuery + { + public bool IncludeDeleted { get; init; } + + public int Skip { get; init; } + public int Take { get; init; } = 50; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs new file mode 100644 index 0000000..68f2948 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileUpdate.cs @@ -0,0 +1,17 @@ +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed record UserProfileUpdate +{ + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Bio { get; init; } + + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } + + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs new file mode 100644 index 0000000..96c2f27 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed record UserIdentifier +{ + public string? TenantId { get; set; } + + public UserKey UserKey { get; init; } + + public UserIdentifierType Type { get; init; } // Email, Phone, Username + public string Value { get; set; } = default!; + + public bool IsPrimary { get; set; } + public bool IsVerified { get; set; } + + public bool IsDeleted { get; set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs deleted file mode 100644 index 292d181..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifierRecord.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public sealed record UserIdentifierRecord - { - public UserKey UserKey { get; init; } - - public UserIdentifierType Type { get; init; } // Email, Phone, Username - public string Value { get; init; } = default!; - - public bool IsPrimary { get; init; } - public bool IsVerified { get; init; } - - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? VerifiedAt { get; init; } - public DateTimeOffset? DeletedAt { get; init; } - } - -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs similarity index 51% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs rename to src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs index 0019d7d..017b25b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/ReferenceUserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -1,24 +1,20 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference.Domain; +namespace CodeBeam.UltimateAuth.Users.Reference; -public sealed record class ReferenceUserProfile +public sealed record class UserLifecycle { - public UserKey UserKey { get; init; } = default!; + public string? TenantId { get; set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - public string? DisplayName { get; set; } - public string? Email { get; init; } - public string? Phone { get; init; } + public UserKey UserKey { get; init; } = default!; public UserStatus Status { get; set; } = UserStatus.Active; + public Guid SecurityStamp { get; set; } public bool IsDeleted { get; set; } + public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? DeletedAt { get; set; } - - public IReadOnlyDictionary? Metadata { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs new file mode 100644 index 0000000..b8cb4a7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +// TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) +public sealed record class UserProfile +{ + public string? TenantId { get; set; } + + public UserKey UserKey { get; init; } = default!; + + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? DisplayName { get; set; } + + public DateOnly? BirthDate { get; set; } + public string? Gender { get; set; } + public string? Bio { get; set; } + + public string? Language { get; set; } + public string? TimeZone { get; set; } + public string? Culture { get; set; } + + public IReadOnlyDictionary? Metadata { get; set; } + + public bool IsDeleted { get; set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs new file mode 100644 index 0000000..87b9902 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserEndpointHandler.cs @@ -0,0 +1,419 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Defaults; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class DefaultUserEndpointHandler : IUserEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserApplicationService _users; + + public DefaultUserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserApplicationService users) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _users = users; + } + + public async Task CreateAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.Create, + resource: "users"); + + var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task ChangeStatusSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.ChangeStatusSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.ChangeStatusAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task GetMeAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.GetSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + var profile = await _users.GetMeAsync(accessContext, ctx.RequestAborted); + return Results.Ok(profile); + } + + public async Task GetUserAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var profile = await _users.GetUserProfileAsync(accessContext, ctx.RequestAborted); + return Results.Ok(profile); + } + + public async Task UpdateMeAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.UpdateSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + await _users.UpdateUserProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UpdateUserAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.UpdateAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.UpdateUserProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.DeleteAdmin, + resource: "users", + resourceId: userKey.Value, + attributes: new Dictionary + { + ["deleteMode"] = request.Mode + }); + + await _users.DeleteUserAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task GetMyIdentifiersAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + var result = await _users.GetIdentifiersByUserAsync(accessContext,ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task GetUserIdentifiersAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var result = await _users.GetIdentifiersByUserAsync(accessContext, ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task AddUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.AddSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.AddUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task AddUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.AddAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.AddUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UpdateUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UpdateSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.UpdateUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UpdateUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UpdateAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.UpdateUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task SetPrimaryUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.SetPrimarySelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.SetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task SetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.SetPrimaryAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.SetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UnsetPrimaryUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UnsetPrimarySelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.UnsetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UnsetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UnsetPrimaryAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.UnsetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task VerifyUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.VerifySelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.VerifyUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task VerifyUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.VerifyAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.VerifyUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.DeleteSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.DeleteUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.DeleteAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.DeleteUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs deleted file mode 100644 index 94019c4..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserLifecycleEndpointHandler.cs +++ /dev/null @@ -1,96 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public sealed class DefaultUserLifecycleEndpointHandler : IUserLifecycleEndpointHandler - { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserLifecycleService _lifecycle; - - public DefaultUserLifecycleEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserLifecycleService lifecycle) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _lifecycle = lifecycle; - } - - public async Task CreateAsync(HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.Users.Create, - resource: "users" - ); - - var result = await _lifecycle.CreateAsync(accessContext, request, ctx.RequestAborted); - - return result.Succeeded - ? Results.Ok(result) - : Results.BadRequest(result); - } - - public async Task ChangeStatusAsync(HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.Users.ChangeStatus, - resource: "users", - resourceId: request.UserKey.Value - ); - - var result = await _lifecycle.ChangeStatusAsync(accessContext, request, ctx.RequestAborted); - - return result.Succeeded - ? Results.Ok(result) - : Results.BadRequest(result); - } - - public async Task DeleteAsync(HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.Users.Delete, - resource: "users", - resourceId: request.UserKey.Value, - attributes: new Dictionary - { - ["deleteMode"] = request.Mode - } - ); - - var result = await _lifecycle.DeleteAsync(accessContext, request, ctx.RequestAborted); - - return result.Succeeded - ? Results.Ok(result) - : Results.BadRequest(result); - } - - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs deleted file mode 100644 index dd7816c..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileAdminEndpointHandler.cs +++ /dev/null @@ -1,61 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -public sealed class DefaultUserProfileAdminEndpointHandler : IUserProfileAdminEndpointHandler -{ - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUserProfileAdminService _profiles; - - public DefaultUserProfileAdminEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserProfileAdminService profiles) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _profiles = profiles; - } - - public async Task GetAsync(UserKey userKey, HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.UserProfiles.GetAdmin, - resource: "users", - resourceId: userKey.Value - ); - - var result = await _profiles.GetAsync(accessContext, userKey, ctx.RequestAborted); - return Results.Ok(result); - } - - public async Task UpdateAsync(UserKey userKey, HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated) - return Results.Unauthorized(); - - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.UserProfiles.UpdateAdmin, - resource: "users", - resourceId: userKey.Value - ); - - await _profiles.UpdateAsync(accessContext, userKey, request, ctx.RequestAborted); - return Results.NoContent(); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs deleted file mode 100644 index 9209327..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/DefaultUserProfileEndpointHandler.cs +++ /dev/null @@ -1,62 +0,0 @@ -using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Defaults; -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public sealed class DefaultUserProfileEndpointHandler : IUserProfileEndpointHandler - { - private readonly IAuthFlowContextAccessor _authFlow; - private readonly IAccessContextFactory _accessContextFactory; - private readonly IUAuthUserProfileService _profiles; - - public DefaultUserProfileEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUAuthUserProfileService profiles) - { - _authFlow = authFlow; - _accessContextFactory = accessContextFactory; - _profiles = profiles; - } - - public async Task GetAsync(HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated || flow.UserKey is null) - return Results.Unauthorized(); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.UserProfiles.GetSelf, - resource: "users", - resourceId: flow.UserKey.Value - ); - - var result = await _profiles.GetCurrentAsync(accessContext, ctx.RequestAborted); - return Results.Ok(result); - } - - public async Task UpdateAsync(HttpContext ctx) - { - var flow = _authFlow.Current; - - if (!flow.IsAuthenticated || flow.UserKey is null) - return Results.Unauthorized(); - - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); - - var accessContext = await _accessContextFactory.CreateAsync( - authFlow: flow, - action: UAuthActions.UserProfiles.UpdateSelf, - resource: "users", - resourceId: flow.UserKey.Value - ); - - await _profiles.UpdateCurrentAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); - } - - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index f2f1edd..30df9de 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -1,7 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Reference; -using CodeBeam.UltimateAuth.Server.Endpoints; -using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -16,13 +14,9 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl // Marker only – runtime validation happens via DI resolution }); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs index e1184ec..ccfee91 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference { public static class UserIdentifierMapper { - public static UserIdentifierDto ToDto(UserIdentifierRecord record) + public static UserIdentifierDto ToDto(UserIdentifier record) => new() { Type = record.Type, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs index 594c63d..116083b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -1,20 +1,36 @@ using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; namespace CodeBeam.UltimateAuth.Users.Reference; internal static class UserProfileMapper { - public static UserProfileDto ToDto(ReferenceUserProfile profile) + public static UserViewDto ToDto(UserProfile profile) => new() { UserKey = profile.UserKey.ToString(), FirstName = profile.FirstName, LastName = profile.LastName, DisplayName = profile.DisplayName, - Email = profile.Email, - Phone = profile.Phone, - Status = profile.Status, + Bio = profile.Bio, + BirthDate = profile.BirthDate, + CreatedAt = profile.CreatedAt, + Gender = profile.Gender, Metadata = profile.Metadata }; + + public static UserProfileUpdate ToUpdate(UpdateProfileRequest request) + => new() + { + FirstName = request.FirstName, + LastName = request.LastName, + DisplayName = request.DisplayName, + BirthDate = request.BirthDate, + Gender = request.Gender, + Bio = request.Bio, + Language = request.Language, + TimeZone = request.TimeZone, + Culture = request.Culture, + Metadata = request.Metadata + }; + } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs deleted file mode 100644 index 64b92e1..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserIdentifierService.cs +++ /dev/null @@ -1,126 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Users.Contracts; -using Microsoft.Extensions.Options; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DefaultUserIdentifierService : IUserIdentifierService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserIdentifierStore _store; - private readonly UAuthServerOptions _serverOptions; - private readonly IClock _clock; - - public DefaultUserIdentifierService(IAccessOrchestrator accessOrchestrator, IUserIdentifierStore store, IOptions serverOptions, IClock clock) - { - _accessOrchestrator = accessOrchestrator; - _store = store; - _serverOptions = serverOptions.Value; - _clock = clock; - } - - public async Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new GetUserIdentifiersCommand( - async innerCt => - { - var records = await _store.GetAllAsync( - context.ResourceTenantId, - targetUserKey, - innerCt); - - var dtos = records - .Where(r => r.DeletedAt is null) - .Select(UserIdentifierMapper.ToDto) - .ToArray(); - - return new GetUserIdentifiersResult - { - Identifiers = dtos - }; - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task ChangeAsync(AccessContext context, UserKey targetUserKey, ChangeUserIdentifierRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var record = new UserIdentifierRecord - { - Type = request.Type, - Value = request.NewValue, - IsVerified = false, - CreatedAt = _clock.UtcNow, - VerifiedAt = null, - DeletedAt = null - }; - - var cmd = new ChangeUserIdentifierCommand( - async innerCt => - { - var exists = await _store.ExistsAsync(context.ResourceTenantId, request.Type, request.NewValue, innerCt); - - if (exists) - throw new InvalidOperationException("identifier_already_exists"); - - await _store.SetAsync(context.ResourceTenantId, targetUserKey, record, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - - return IdentifierChangeResult.Success(); - } - - public async Task VerifyAsync(AccessContext context, UserKey targetUserKey, VerifyUserIdentifierRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new VerifyUserIdentifierCommand( - async innerCt => - { - await _store.MarkVerifiedAsync(context.ResourceTenantId, targetUserKey, request.Type, _clock.UtcNow, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - - return IdentifierVerificationResult.Success(); - } - - public async Task DeleteAsync(AccessContext context, UserKey targetUserKey, DeleteUserIdentifierRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new DeleteUserIdentifierCommand( - async innerCt => - { - var identifiers = await _store.GetByTypeAsync(context.ResourceTenantId, targetUserKey, request.Type, innerCt); - - var activeCount = identifiers.Count(i => i.DeletedAt is null); - - if (activeCount <= 1 && request.Type == UserIdentifierType.Username) - throw new InvalidOperationException("last_username_cannot_be_deleted"); - - await _store.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Type, request.Value, request.Mode, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - - return IdentifierDeleteResult.Success(); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs deleted file mode 100644 index 325fd0c..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserLifecycleService.cs +++ /dev/null @@ -1,184 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Credentials.Contracts; -using CodeBeam.UltimateAuth.Credentials.Reference; -using CodeBeam.UltimateAuth.Credentials.Reference.Internal; -using CodeBeam.UltimateAuth.Server.Defaults; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - internal sealed class DefaultUserLifecycleService : IUserLifecycleService - { - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserStore _users; - private readonly IUserProfileStore _profiles; - private readonly IUserLifecycleStore _userLifecycleStore; - private readonly IUserCredentialsService _credentials; - private readonly IUserCredentialsInternalService _credentialsInternal; - private readonly IUserIdentifierService _identifierService; - private readonly ISessionService _sessionService; - private readonly IAuthContextFactory _authContextFactory; - private readonly IClock _clock; - - public DefaultUserLifecycleService( - IAccessOrchestrator accessOrchestrator, - IUserStore users, - IUserProfileStore profiles, - IUserLifecycleStore userLifecycleStore, - IUserCredentialsService credentials, - IUserCredentialsInternalService credentialsInternal, - IUserIdentifierService identifierService, - ISessionService sessionService, - IAuthContextFactory authContextFactory, - IClock clock) - { - _accessOrchestrator = accessOrchestrator; - _users = users; - _profiles = profiles; - _userLifecycleStore = userLifecycleStore; - _credentials = credentials; - _credentialsInternal = credentialsInternal; - _identifierService = identifierService; - _sessionService = sessionService; - _authContextFactory = authContextFactory; - _clock = clock; - } - - public async Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(request.Identifier)) - return UserCreateResult.Failed("identifier_required"); - - var policies = Array.Empty(); - var userKey = UserKey.New(); - var now = _clock.UtcNow; - - var cmd = new CreateUserCommand( - async innerCt => - { - var existing = await _users.FindByLoginAsync(context.ResourceTenantId, request.Identifier, innerCt); - - if (existing is not null) - throw new InvalidOperationException("user_already_exists"); - - var profile = new ReferenceUserProfile - { - UserKey = userKey, - Email = request.Identifier, - DisplayName = request.DisplayName, - Status = UserStatus.Active, - IsDeleted = false, - CreatedAt = now, - UpdatedAt = now, - DeletedAt = null, - FirstName = request.Profile?.FirstName, - LastName = request.Profile?.LastName, - }; - - await _userLifecycleStore.CreateAsync(context.ResourceTenantId, profile, innerCt); - await _profiles.CreateAsync(context.ResourceTenantId, profile, innerCt); - - if (!string.IsNullOrWhiteSpace(request.Password)) - { - await _credentials.AddAsync( - new AccessContext - { - ActorUserKey = context.ActorUserKey, - ActorTenantId = context.ActorTenantId, - IsAuthenticated = context.IsAuthenticated, - - Resource = "credentials", - ResourceId = userKey.Value, - ResourceTenantId = context.ResourceTenantId, - - Action = UAuthActions.Credentials.Add - }, - new AddCredentialRequest - { - Type = CredentialType.Password, - Secret = request.Password - }, - innerCt); - - } - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - return UserCreateResult.Success(userKey); - } - - public async Task ChangeStatusAsync(AccessContext context, ChangeUserStatusRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - UserStatus oldStatus = default; - - var cmd = new ChangeUserStatusCommand( - async innerCt => - { - var profile = await _profiles.GetAsync(context.ResourceTenantId, request.UserKey, innerCt); - - if (profile is null) - throw new InvalidOperationException("user_not_found"); - - if (profile.Status == request.NewStatus) - return; - - oldStatus = profile.Status; - //await _profiles.SetStatusAsync(context.ResourceTenantId, request.UserKey, request.NewStatus, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - return UserStatusChangeResult.Success(oldStatus, request.NewStatus); - } - - public async Task DeleteAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new DeleteUserCommand( - async innerCt => - { - var user = await _users.FindByIdAsync(context.ResourceTenantId, request.UserKey, innerCt); - - if (user is null) - throw new InvalidOperationException("user_not_found"); - - var authContext = _authContextFactory.Create(); - if (request.Mode == DeleteMode.Soft) - { - if (user.IsDeleted) - return; - - await _userLifecycleStore.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Soft, _clock.UtcNow, innerCt); - await _sessionService.RevokeAllAsync(authContext, request.UserKey,innerCt); - return; - } - - // Hard delete - if (!user.IsDeleted) - { - await _userLifecycleStore.DeleteAsync(context.ResourceTenantId,request.UserKey, DeleteMode.Soft, _clock.UtcNow, innerCt); - } - - await _sessionService.RevokeAllAsync(authContext, request.UserKey, innerCt); - await _credentialsInternal.DeleteInternalAsync(context.ResourceTenantId, request.UserKey, innerCt); - await _profiles.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Hard, innerCt); - await _userLifecycleStore.DeleteAsync(context.ResourceTenantId, request.UserKey, DeleteMode.Hard, _clock.UtcNow, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - return UserDeleteResult.Success(request.Mode); - } - - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs deleted file mode 100644 index c01fb80..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileAdminService.cs +++ /dev/null @@ -1,54 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DefaultUserProfileAdminService : IUserProfileAdminService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserProfileStore _profiles; - - public DefaultUserProfileAdminService(IAccessOrchestrator accessOrchestrator, IUserProfileStore profiles) - { - _accessOrchestrator = accessOrchestrator; - _profiles = profiles; - } - - public async Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new GetUserProfileAdminCommand( - async innerCt => - { - var profile = await _profiles.GetAsync(context.ResourceTenantId, targetUserKey, innerCt); - - if (profile is null) - throw new InvalidOperationException("user_profile_not_found"); - - return UserProfileMapper.ToDto(profile); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task UpdateAsync(AccessContext context, UserKey targetUserKey, UpdateProfileRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new UpdateUserProfileAdminCommand( - async innerCt => - { - await _profiles.UpdateAsync(context.ResourceTenantId, targetUserKey, request, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs deleted file mode 100644 index af43cbb..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/DefaultUserProfileService.cs +++ /dev/null @@ -1,60 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -internal sealed class DefaultUserProfileService : IUAuthUserProfileService -{ - private readonly IAccessOrchestrator _accessOrchestrator; - private readonly IUserProfileStore _profiles; - - public DefaultUserProfileService(IAccessOrchestrator accessOrchestrator, IUserProfileStore profiles) - { - _accessOrchestrator = accessOrchestrator; - _profiles = profiles; - } - - public async Task GetCurrentAsync(AccessContext context, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new GetCurrentUserProfileCommand( - async innerCt => - { - if (context.ActorUserKey is null) - throw new UnauthorizedAccessException(); - - var profile = await _profiles.GetAsync(context.ResourceTenantId, (UserKey)context.ActorUserKey, innerCt); - - if (profile is null) - throw new InvalidOperationException("user_profile_not_found"); - - return UserProfileMapper.ToDto(profile); - }); - - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } - - public async Task UpdateCurrentAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - var policies = Array.Empty(); - - var cmd = new UpdateCurrentUserProfileCommand( - async innerCt => - { - if (context.ActorUserKey is null) - throw new UnauthorizedAccessException(); - - await _profiles.UpdateAsync(context.ResourceTenantId, (UserKey)context.ActorUserKey, request, innerCt); - }); - - await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs new file mode 100644 index 0000000..494ad49 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -0,0 +1,37 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + public interface IUserApplicationService + { + Task GetMeAsync(AccessContext context, CancellationToken ct = default); + Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + + Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); + + Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); + + Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); + + Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default); + + Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + + Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + + Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default); + + Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default); + + Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default); + + Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default); + + Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default); + + Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default); + + Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs deleted file mode 100644 index d70e1a5..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public interface IUserIdentifierService - { - Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); - Task ChangeAsync(AccessContext context, UserKey targetUserKey, ChangeUserIdentifierRequest request, CancellationToken ct = default); - Task VerifyAsync(AccessContext context, UserKey targetUserKey, VerifyUserIdentifierRequest request, CancellationToken ct = default); - Task DeleteAsync(AccessContext context, UserKey targetUserKey, DeleteUserIdentifierRequest request, CancellationToken ct = default); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs deleted file mode 100644 index c416e87..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserLifecycleService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference; - -public interface IUserLifecycleService -{ - Task CreateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); - Task ChangeStatusAsync(AccessContext context, ChangeUserStatusRequest request, CancellationToken ct = default); - Task DeleteAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs deleted file mode 100644 index 5b8f402..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileAdminService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public interface IUserProfileAdminService - { - Task GetAsync(AccessContext context, UserKey targetUserKey, CancellationToken ct = default); - Task UpdateAsync(AccessContext context, UserKey targetUserKey, UpdateProfileRequest request, CancellationToken ct = default); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs deleted file mode 100644 index 1c96833..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserProfileService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Users.Reference -{ - public interface IUAuthUserProfileService - { - Task GetCurrentAsync(AccessContext context, CancellationToken ct = default); - Task UpdateCurrentAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs new file mode 100644 index 0000000..8d396f8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -0,0 +1,377 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference.Commands; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserApplicationService : IUserApplicationService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserLifecycleStore _lifecycleStore; + private readonly IUserProfileStore _profileStore; + private readonly IUserIdentifierStore _identifierStore; + private readonly IEnumerable _integrations; + private readonly IClock _clock; + + public UserApplicationService( + IAccessOrchestrator accessOrchestrator, + IUserLifecycleStore lifecycleStore, + IUserProfileStore profileStore, + IUserIdentifierStore identifierStore, + IEnumerable integrations, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _lifecycleStore = lifecycleStore; + _profileStore = profileStore; + _identifierStore = identifierStore; + _integrations = integrations; + _clock = clock; + } + + public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetMeCommand(async innerCt => + { + if (context.ActorUserKey is null) + throw new UnauthorizedAccessException(); + + return await BuildUserViewAsync(context.ResourceTenantId, context.ActorUserKey.Value, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetUserProfileCommand(async innerCt => + { + // Target user MUST exist in context + var targetUserKey = context.GetTargetUserKey(); + + return await BuildUserViewAsync(context.ResourceTenantId, targetUserKey, innerCt); + + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + { + var command = new CreateUserCommand(async innerCt => + { + var now = _clock.UtcNow; + var userKey = UserKey.New(); + + if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is null) + { + return UserCreateResult.Failed("primary_identifier_type_required"); + } + + await _lifecycleStore.CreateAsync(context.ResourceTenantId, + new UserLifecycle + { + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = now + }, + innerCt); + + await _profileStore.CreateAsync(context.ResourceTenantId, + new UserProfile + { + UserKey = userKey, + FirstName = request.FirstName, + LastName = request.LastName, + DisplayName = request.DisplayName, + BirthDate = request.BirthDate, + Gender = request.Gender, + Bio = request.Bio, + Language = request.Language, + TimeZone = request.TimeZone, + Culture = request.Culture, + Metadata = request.Metadata, + CreatedAt = now + }, + innerCt); + + if (!string.IsNullOrWhiteSpace(request.PrimaryIdentifierValue) && request.PrimaryIdentifierType is not null) + { + await _identifierStore.CreateAsync(context.ResourceTenantId, + new UserIdentifier + { + UserKey = userKey, + Type = request.PrimaryIdentifierType.Value, + Value = request.PrimaryIdentifierValue, + IsPrimary = true, + IsVerified = request.PrimaryIdentifierVerified, + CreatedAt = now, + VerifiedAt = request.PrimaryIdentifierVerified ? now : null + }, + innerCt); + } + + foreach (var integration in _integrations) + { + await integration.OnUserCreatedAsync(context.ResourceTenantId, userKey, request, innerCt); + } + + return UserCreateResult.Success(userKey); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) + { + var command = new ChangeUserStatusCommand(async innerCt => + { + var newStatus = request switch + { + ChangeUserStatusSelfRequest r => r.NewStatus, + ChangeUserStatusAdminRequest r => r.NewStatus, + _ => throw new InvalidOperationException("invalid_request") + }; + + var targetUserKey = context.GetTargetUserKey(); + var current = await _lifecycleStore.GetAsync(context.ResourceTenantId, targetUserKey, innerCt); + + if (current is null) + throw new InvalidOperationException("user_not_found"); + + if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) + throw new InvalidOperationException("self_transition_not_allowed"); + + if (!context.IsSelfAction) + { + if (newStatus is UserStatus.SelfSuspended or UserStatus.Deactivated) + throw new InvalidOperationException("admin_cannot_set_self_status"); + } + + await _lifecycleStore.ChangeStatusAsync(context.ResourceTenantId, targetUserKey, newStatus, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) + { + var command = new UpdateUserProfileCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var update = UserProfileMapper.ToUpdate(request); + + await _profileStore.UpdateAsync(context.ResourceTenantId, targetUserKey, update, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task> GetIdentifiersByUserAsync(AccessContext context, CancellationToken ct = default) + { + var command = new GetUserIdentifiersCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, targetUserKey, innerCt); + + return identifiers.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) + { + var command = new GetUserIdentifierCommand(async innerCt => + { + var identifier = await _identifierStore.GetAsync(context.ResourceTenantId, type, value, innerCt); + return identifier is null + ? null + : UserIdentifierMapper.ToDto(identifier); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) + { + var command = new UserIdentifierExistsCommand(async innerCt => + { + return await _identifierStore.ExistsAsync(context.ResourceTenantId, type, value, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AddUserIdentifierCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + await _identifierStore.CreateAsync(context.ResourceTenantId, + new UserIdentifier + { + UserKey = userKey, + Type = request.Type, + Value = request.Value, + IsPrimary = request.IsPrimary, + IsVerified = false, + CreatedAt = _clock.UtcNow + }, + innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new UpdateUserIdentifierCommand(async innerCt => + { + if (string.Equals(request.OldValue, request.NewValue, StringComparison.Ordinal)) + throw new InvalidOperationException("identifier_value_unchanged"); + + await _identifierStore.UpdateValueAsync(context.ResourceTenantId, request.Type, request.OldValue, request.NewValue, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new SetPrimaryUserIdentifierCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + await _identifierStore.SetPrimaryAsync(context.ResourceTenantId, userKey, request.Type, request.Value, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new UnsetPrimaryUserIdentifierCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, userKey, innerCt); + var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + + if (target is null) + throw new InvalidOperationException("identifier_not_found"); + + if (!target.IsPrimary) + throw new InvalidOperationException("identifier_not_primary"); + + var otherLoginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type) && !(i.Type == target.Type && i.Value == target.Value)).ToList(); + if (otherLoginIdentifiers.Count == 0) + throw new InvalidOperationException("cannot_unset_last_primary_login_identifier"); + + await _identifierStore.UnsetPrimaryAsync(context.ResourceTenantId, userKey, target.Type, target.Value, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new VerifyUserIdentifierCommand(async innerCt => + { + await _identifierStore.MarkVerifiedAsync(context.ResourceTenantId, request.Type, request.Value, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new DeleteUserIdentifierCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + + var identifiers = await _identifierStore.GetByUserAsync(context.ResourceTenantId, targetUserKey, innerCt); + var target = identifiers.FirstOrDefault(i => i.Type == request.Type && string.Equals(i.Value, request.Value, StringComparison.OrdinalIgnoreCase) && !i.IsDeleted); + + if (target is null) + throw new InvalidOperationException("identifier_not_found"); + + var loginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type)).ToList(); + if (IsLoginIdentifier(target.Type) && loginIdentifiers.Count == 1) + throw new InvalidOperationException("cannot_delete_last_login_identifier"); + + if (target.IsPrimary) + throw new InvalidOperationException("cannot_delete_primary_identifier"); + + await _identifierStore.DeleteAsync(context.ResourceTenantId, request.Type, request.Value, request.Mode, _clock.UtcNow, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + { + var command = new DeleteUserCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + await _lifecycleStore.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); + await _identifierStore.DeleteByUserAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); + await _profileStore.DeleteAsync(context.ResourceTenantId, targetUserKey, request.Mode, now, innerCt); + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenantId, targetUserKey, request.Mode, innerCt); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + private async Task BuildUserViewAsync(string? tenantId, UserKey userKey, CancellationToken ct) + { + var profile = await _profileStore.GetAsync(tenantId, userKey, ct); + + if (profile is null || profile.IsDeleted) + throw new InvalidOperationException("user_profile_not_found"); + + var identifiers = await _identifierStore.GetByUserAsync(tenantId, userKey, ct); + + var username = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Username && x.IsPrimary); + var primaryEmail = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + var primaryPhone = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Phone && x.IsPrimary); + + var dto = UserProfileMapper.ToDto(profile); + + return dto with + { + UserName = username?.Value, + PrimaryEmail = primaryEmail?.Value, + PrimaryPhone = primaryPhone?.Value, + EmailVerified = primaryEmail?.IsVerified ?? false, + PhoneVerified = primaryPhone?.IsVerified ?? false + }; + } + + private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) + => (from, to) switch + { + (UserStatus.Active, UserStatus.SelfSuspended) => true, + (UserStatus.SelfSuspended, UserStatus.Active) => true, + (UserStatus.Active or UserStatus.SelfSuspended, UserStatus.Deactivated) => true, + _ => false + }; + + private static bool IsLoginIdentifier(UserIdentifierType type) + => type is + UserIdentifierType.Username or + UserIdentifierType.Email or + UserIdentifierType.Phone; + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index f07c2dc..71a42be 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -2,15 +2,27 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserIdentifierStore { - public interface IUserIdentifierStore - { - Task> GetAllAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - Task> GetByTypeAsync(string? tenantId, UserKey userKey, UserIdentifierType type, CancellationToken ct = default); - Task SetAsync(string? tenantId, UserKey userKey, UserIdentifierRecord record, CancellationToken ct = default); - Task MarkVerifiedAsync(string? tenantId, UserKey userKey, UserIdentifierType type, DateTimeOffset verifiedAt, CancellationToken ct = default); - Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, DeleteMode mode, CancellationToken ct = default); - } + Task ExistsAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + + Task> GetByUserAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task GetAsync(string? tenantId, UserIdentifierType type, string value, CancellationToken ct = default); + + Task CreateAsync(string? tenantId, UserIdentifier identifier, CancellationToken ct = default); + + Task UpdateValueAsync(string? tenantId, UserIdentifierType type, string oldValue, string newValue, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task MarkVerifiedAsync(string? tenantId, UserIdentifierType type, string value, DateTimeOffset verifiedAt, CancellationToken ct = default); + + Task SetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + + Task UnsetPrimaryAsync(string? tenantId, UserKey userKey, UserIdentifierType type, string value, CancellationToken ct = default); + + Task DeleteAsync(string? tenantId, UserIdentifierType type, string value, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); + + Task DeleteByUserAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs index 15f279e..44507c8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,14 +1,23 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; namespace CodeBeam.UltimateAuth.Users.Reference { public interface IUserLifecycleStore { - Task CreateAsync(string? tenantId, ReferenceUserProfile user, CancellationToken ct = default); - Task UpdateStatusAsync(string? tenantId, UserKey userKey, UserStatus status, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset at, CancellationToken ct = default); + Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task> QueryAsync(string? tenantId, UserLifecycleQuery query, CancellationToken ct = default); + + Task CreateAsync(string? tenantId, UserLifecycle lifecycle, CancellationToken ct = default); + + Task ChangeStatusAsync(string? tenantId, UserKey userKey, UserStatus newStatus, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task ChangeSecurityStampAsync(string? tenantId, UserKey userKey, Guid newSecurityStamp, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs index c2f300d..7850208 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -1,15 +1,19 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference.Domain; namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserProfileStore { - // TODO: Do CreateAsync internal with initializer service - Task CreateAsync(string? tenantId, ReferenceUserProfile profile, CancellationToken ct = default); - Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); - Task UpdateAsync(string? tenantId, UserKey userKey, UpdateProfileRequest request, CancellationToken ct = default); - Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default); + Task ExistsAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default); + + Task> QueryAsync(string? tenantId, UserProfileQuery query, CancellationToken ct = default); + + Task CreateAsync(string? tenantId, UserProfile profile, CancellationToken ct = default); + + Task UpdateAsync(string? tenantId, UserKey userKey, UserProfileUpdate update, DateTimeOffset updatedAt, CancellationToken ct = default); + + Task DeleteAsync(string? tenantId, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs new file mode 100644 index 0000000..8c02456 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStore.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference +{ + internal sealed class UserRuntimeStore : IUserRuntimeStateProvider + { + private readonly IUserLifecycleStore _lifecycleStore; + + public UserRuntimeStore(IUserLifecycleStore lifecycleStore) + { + _lifecycleStore = lifecycleStore; + } + + public async Task GetAsync(string? tenantId, UserKey userKey, CancellationToken ct = default) + { + var lifecycle = await _lifecycleStore.GetAsync(tenantId, userKey, ct); + + if (lifecycle is null) + return null; + + return new UserRuntimeRecord + { + UserKey = lifecycle.UserKey, + IsActive = lifecycle.Status == UserStatus.Active, + IsDeleted = lifecycle.IsDeleted, + Exists = true + }; + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs new file mode 100644 index 0000000..130390a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Abstractions; + +/// +/// Optional integration point for reacting to user lifecycle events. +/// Implemented by plugin domains (Credentials, Authorization, Audit, etc). +/// +public interface IUserLifecycleIntegration +{ + Task OnUserCreatedAsync(string? tenantId, UserKey userKey, object request, CancellationToken ct = default); + + Task OnUserDeletedAsync(string? tenantId, UserKey userKey, DeleteMode mode, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs deleted file mode 100644 index 1534c02..0000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserStore.cs +++ /dev/null @@ -1,25 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Infrastructure; - -namespace CodeBeam.UltimateAuth.Users -{ - public interface IUserStore - { - /// - /// Finds a user by its application-level user id. - /// Returns null if the user does not exist or is deleted. - /// - Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default); - - /// - /// Finds a user by a login identifier (username, email, etc). - /// Used during login discovery phase. - /// - Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default); - - /// - /// Checks whether a user exists. - /// Fast-path helper for authorities. - /// - Task ExistsAsync(string? tenantId, TUserId userId, CancellationToken ct = default); - } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj index 1f3e2de..aacabcc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj +++ b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs new file mode 100644 index 0000000..ff7c37b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -0,0 +1,34 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Policies +{ + public class ActionTextTests + { + [Theory] + [InlineData("users.profile.get.admin", true)] + [InlineData("users.profile.get.self", false)] + [InlineData("users.profile.get", false)] + public void RequireAdminPolicy_AppliesTo_Works(string action, bool expected) + { + var context = new AccessContext { Action = action }; + var policy = new RequireAdminPolicy(); + + Assert.Equal(expected, policy.AppliesTo(context)); + } + + [Fact] + public void RequireAdminPolicy_DoesNotMatch_Substrings() + { + var context = new AccessContext + { + Action = "users.profile.get.administrator" + }; + + var policy = new RequireAdminPolicy(); + + Assert.False(policy.AppliesTo(context)); + } + + } +}