From a4355339986ed2b4d065b0e7fd457b273c343702 Mon Sep 17 00:00:00 2001 From: Eben Date: Sun, 23 Aug 2026 10:23:52 +0200 Subject: [PATCH 1/2] Tenant maximum identities. --- .../Messages/RegisterTenant.cs | 1 + .../RegisterTenantParticipant.cs | 2 +- .../SetIdentityTenantStatusParticipant.cs | 14 +- Shuttle.Access.Messages/v1/RegisterTenant.cs | 1 + .../v1/EventHandlers/TenantHandler.cs | 3 +- .../MessageHandlers/RegisterTenantHandler.cs | 1 + ...antMaximumIdentities_Migration.Designer.cs | 419 ++++++++++++++++++ ...52709_TenantMaximumIdentities_Migration.cs | 31 ++ .../AccessDbContextModelSnapshot.cs | 5 +- Shuttle.Access.SqlServer/Models/Tenant.cs | 3 + Shuttle.Access.SqlServer/TenantQuery.cs | 3 +- .../DataAccess.Sql/PermissionQueryFixture.cs | 4 +- .../DataAccess.Sql/RoleQueryFixture.cs | 4 +- .../FixtureWebApplicationFactory.cs | 16 +- .../WebApi.v1/IdentitiesFixture.cs | 200 ++++++--- .../WebApi.v1/RolesFixture.cs | 8 +- ...tIdentityTenantStatusParticipantFixture.cs | 118 +++++ .../v1/RegisterTenant.cs | 1 + Shuttle.Access.WebApi.Contracts/v1/Tenant.cs | 1 + .../Endpoints/IdentityEndpoints.cs | 20 +- .../Endpoints/TenantEndpoints.cs | 5 +- Shuttle.Access/Events/Tenant.v1/Registered.cs | 1 + Shuttle.Access/Query/Tenant.cs | 1 + Shuttle.Access/Resources.Designer.cs | 9 + Shuttle.Access/Resources.resx | 4 + Shuttle.Access/Tenant.cs | 7 +- 26 files changed, 800 insertions(+), 82 deletions(-) create mode 100644 Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.Designer.cs create mode 100644 Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.cs create mode 100644 Shuttle.Access.Tests/Participants/SetIdentityTenantStatusParticipantFixture.cs diff --git a/Shuttle.Access.Application/Messages/RegisterTenant.cs b/Shuttle.Access.Application/Messages/RegisterTenant.cs index 41fb35c9..9f7bbdcb 100644 --- a/Shuttle.Access.Application/Messages/RegisterTenant.cs +++ b/Shuttle.Access.Application/Messages/RegisterTenant.cs @@ -8,6 +8,7 @@ public class RegisterTenant(Guid id, string name, TenantStatus status, Guid audi public Guid Id { get; } = Guard.AgainstEmpty(id); public string LogoSvg { get; set; } = string.Empty; public string LogoUrl { get; set; } = string.Empty; + public int MaximumIdentities { get; set; } public string Name { get; } = Guard.AgainstEmpty(name); public TenantStatus Status { get; } = Guard.AgainstUndefinedEnum(status); public string AuditIdentityName { get; } = Guard.AgainstEmpty(auditIdentityName); diff --git a/Shuttle.Access.Application/RegisterTenantParticipant.cs b/Shuttle.Access.Application/RegisterTenantParticipant.cs index 33deb2a1..8bcc2c90 100644 --- a/Shuttle.Access.Application/RegisterTenantParticipant.cs +++ b/Shuttle.Access.Application/RegisterTenantParticipant.cs @@ -35,7 +35,7 @@ public async Task HandleAsync(RegisterTenant message, CancellationToken cancella if (string.IsNullOrWhiteSpace(aggregate.Name)) { - stream.Add(aggregate.Register(message.Name, (int)message.Status, message.LogoSvg, message.LogoUrl)); + stream.Add(aggregate.Register(message.Name, (int)message.Status, message.LogoSvg, message.LogoUrl, message.MaximumIdentities)); await eventStore.SaveAsync(stream, builder => builder.Audit(message), cancellationToken); } diff --git a/Shuttle.Access.Application/SetIdentityTenantStatusParticipant.cs b/Shuttle.Access.Application/SetIdentityTenantStatusParticipant.cs index f185231c..a493661e 100644 --- a/Shuttle.Access.Application/SetIdentityTenantStatusParticipant.cs +++ b/Shuttle.Access.Application/SetIdentityTenantStatusParticipant.cs @@ -4,7 +4,7 @@ namespace Shuttle.Access.Application; -public class SetIdentityTenantStatusParticipant(IEventStore eventStore) : IParticipant +public class SetIdentityTenantStatusParticipant(IEventStore eventStore, ITenantQuery tenantQuery, IIdentityQuery identityQuery) : IParticipant { public async Task HandleAsync(SetIdentityTenantStatus message, CancellationToken cancellationToken = default) { @@ -17,6 +17,18 @@ public async Task HandleAsync(SetIdentityTenantStatus message, CancellationToken if (message.Active && !identity.IsInTenant(message.TenantId)) { + var tenant = (await tenantQuery.SearchAsync(new Query.Tenant.Specification().AddId(message.TenantId), cancellationToken)).FirstOrDefault(); + + if (tenant is { MaximumIdentities: > 0 }) + { + var identityCount = await identityQuery.CountAsync(new Query.Identity.Specification().WithTenantId(message.TenantId), cancellationToken); + + if (identityCount >= tenant.MaximumIdentities) + { + throw new ApplicationException(string.Format(Access.Resources.TenantMaximumIdentitiesExceededException, tenant.Name, tenant.MaximumIdentities)); + } + } + stream.Add(identity.AddTenant(message.TenantId)); } diff --git a/Shuttle.Access.Messages/v1/RegisterTenant.cs b/Shuttle.Access.Messages/v1/RegisterTenant.cs index 0a9d7741..d2d7634f 100644 --- a/Shuttle.Access.Messages/v1/RegisterTenant.cs +++ b/Shuttle.Access.Messages/v1/RegisterTenant.cs @@ -7,6 +7,7 @@ public class RegisterTenant : AuditMessage public string LogoSvg { get; set; } = string.Empty; public string LogoUrl { get; set; } = string.Empty; public int Status { get; set; } = 1; + public int MaximumIdentities { get; set; } public string AdministratorIdentityName { get; set; } = string.Empty; public Guid AccessAdministratorRoleId { get; set; } } \ No newline at end of file diff --git a/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs b/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs index 489b6998..42943412 100644 --- a/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs +++ b/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs @@ -34,7 +34,8 @@ public async Task HandleAsync(IEventHandlerContext context, Cancella Name = context.Event.Name, Status = context.Event.Status, LogoSvg = context.Event.LogoSvg, - LogoUrl = context.Event.LogoUrl + LogoUrl = context.Event.LogoUrl, + MaximumIdentities = context.Event.MaximumIdentities }); await _accessDbContext.SaveChangesAsync(cancellationToken); diff --git a/Shuttle.Access.Server/v1/MessageHandlers/RegisterTenantHandler.cs b/Shuttle.Access.Server/v1/MessageHandlers/RegisterTenantHandler.cs index f1970665..ae26207c 100644 --- a/Shuttle.Access.Server/v1/MessageHandlers/RegisterTenantHandler.cs +++ b/Shuttle.Access.Server/v1/MessageHandlers/RegisterTenantHandler.cs @@ -14,6 +14,7 @@ public async Task HandleAsync(RegisterTenant message, CancellationToken cancella { LogoUrl = message.LogoUrl, LogoSvg = message.LogoSvg, + MaximumIdentities = message.MaximumIdentities, AdministratorIdentityName = message.AdministratorIdentityName, AccessAdministratorRoleId = message.AccessAdministratorRoleId }; diff --git a/Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.Designer.cs b/Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.Designer.cs new file mode 100644 index 00000000..5bf31146 --- /dev/null +++ b/Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.Designer.cs @@ -0,0 +1,419 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Shuttle.Access.SqlServer; + +#nullable disable + +namespace Shuttle.Access.Data.Migrations +{ + [DbContext(typeof(AccessDbContext))] + [Migration("20260823052709_TenantMaximumIdentities_Migration")] + partial class TenantMaximumIdentities_Migration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Identity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateActivated") + .HasColumnType("datetimeoffset"); + + b.Property("DateRegistered") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GeneratedPassword") + .HasMaxLength(65) + .HasColumnType("nvarchar(65)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("RegisteredBy") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Name" }, "UX_Identity_Name") + .IsUnique(); + + b.ToTable("Identity", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.IdentityRole", b => + { + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("IdentityId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("TenantId", "IdentityId", "RoleId"); + + b.HasIndex("IdentityId"); + + b.HasIndex("RoleId"); + + b.ToTable("IdentityRole", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.IdentityTenant", b => + { + b.Property("IdentityId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("IdentityId", "TenantId"); + + b.HasIndex("TenantId"); + + b.ToTable("IdentityTenant", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Name" }, "UX_Permission_Name") + .IsUnique(); + + b.ToTable("Permission", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Role", b => + { + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("TenantId", "Id"); + + b.HasIndex(new[] { "Id" }, "UX_Role_Id") + .IsUnique(); + + b.HasIndex(new[] { "TenantId", "Name" }, "UX_Role_TenantId_Name") + .IsUnique(); + + b.ToTable("Role", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.RolePermission", b => + { + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("PermissionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("TenantId", "RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("RolePermission", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Session", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWID()"); + + b.Property("DateRegistered") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiryDate") + .HasColumnType("datetimeoffset"); + + b.Property("IdentityId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "IdentityId" }, "UX_Session_IdentityId") + .IsUnique(); + + b.ToTable("Session", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.SessionPermission", b => + { + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("PermissionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("SessionId", "TenantId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("SessionPermission", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.SessionToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Application") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DateRegistered") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiryDate") + .HasColumnType("datetimeoffset"); + + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "SessionId", "TokenHash" }, "UX_SessionToken_SessionId_TokenHash") + .IsUnique(); + + b.ToTable("SessionToken", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LogoSvg") + .IsRequired() + .HasMaxLength(2147483647) + .HasColumnType("nvarchar(max)"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("MaximumIdentities") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Name" }, "UX_Tenant_Name") + .IsUnique(); + + b.ToTable("Tenant", "access"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.IdentityRole", b => + { + b.HasOne("Shuttle.Access.SqlServer.Models.Identity", "Identity") + .WithMany("IdentityRoles") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Shuttle.Access.SqlServer.Models.Role", "Role") + .WithMany("IdentityRoles") + .HasForeignKey("RoleId") + .HasPrincipalKey("Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Identity"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.IdentityTenant", b => + { + b.HasOne("Shuttle.Access.SqlServer.Models.Identity", "Identity") + .WithMany("IdentityTenants") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Shuttle.Access.SqlServer.Models.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Identity"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Role", b => + { + b.HasOne("Shuttle.Access.SqlServer.Models.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.RolePermission", b => + { + b.HasOne("Shuttle.Access.SqlServer.Models.Permission", "Permission") + .WithMany("RolePermissions") + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Shuttle.Access.SqlServer.Models.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("TenantId", "RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Session", b => + { + b.HasOne("Shuttle.Access.SqlServer.Models.Identity", "Identity") + .WithMany() + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Identity"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.SessionPermission", b => + { + b.HasOne("Shuttle.Access.SqlServer.Models.Permission", "Permission") + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Shuttle.Access.SqlServer.Models.Session", "Session") + .WithMany("Permissions") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.SessionToken", b => + { + b.HasOne("Shuttle.Access.SqlServer.Models.Session", "Session") + .WithMany("Tokens") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Identity", b => + { + b.Navigation("IdentityRoles"); + + b.Navigation("IdentityTenants"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Permission", b => + { + b.Navigation("RolePermissions"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Role", b => + { + b.Navigation("IdentityRoles"); + + b.Navigation("RolePermissions"); + }); + + modelBuilder.Entity("Shuttle.Access.SqlServer.Models.Session", b => + { + b.Navigation("Permissions"); + + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.cs b/Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.cs new file mode 100644 index 00000000..b4f9fe23 --- /dev/null +++ b/Shuttle.Access.SqlServer/Migrations/20260823052709_TenantMaximumIdentities_Migration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Shuttle.Access.Data.Migrations +{ + /// + public partial class TenantMaximumIdentities_Migration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MaximumIdentities", + schema: "access", + table: "Tenant", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaximumIdentities", + schema: "access", + table: "Tenant"); + } + } +} diff --git a/Shuttle.Access.SqlServer/Migrations/AccessDbContextModelSnapshot.cs b/Shuttle.Access.SqlServer/Migrations/AccessDbContextModelSnapshot.cs index 92c3bfb7..f80eee46 100644 --- a/Shuttle.Access.SqlServer/Migrations/AccessDbContextModelSnapshot.cs +++ b/Shuttle.Access.SqlServer/Migrations/AccessDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -256,6 +256,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(2048) .HasColumnType("nvarchar(2048)"); + b.Property("MaximumIdentities") + .HasColumnType("int"); + b.Property("Name") .IsRequired() .HasMaxLength(320) diff --git a/Shuttle.Access.SqlServer/Models/Tenant.cs b/Shuttle.Access.SqlServer/Models/Tenant.cs index 5243b790..0b1cebaf 100644 --- a/Shuttle.Access.SqlServer/Models/Tenant.cs +++ b/Shuttle.Access.SqlServer/Models/Tenant.cs @@ -23,4 +23,7 @@ public class Tenant [StringLength(2048)] public string LogoUrl { get; set; } = string.Empty; + + [Required] + public int MaximumIdentities { get; set; } } \ No newline at end of file diff --git a/Shuttle.Access.SqlServer/TenantQuery.cs b/Shuttle.Access.SqlServer/TenantQuery.cs index 4ae38cdb..4a3ad2f4 100644 --- a/Shuttle.Access.SqlServer/TenantQuery.cs +++ b/Shuttle.Access.SqlServer/TenantQuery.cs @@ -23,7 +23,8 @@ public async ValueTask CountAsync(Query.Tenant.Specification specification, Name = e.Name, Status = (TenantStatus) e.Status, LogoUrl = e.LogoUrl, - LogoSvg = e.LogoSvg + LogoSvg = e.LogoSvg, + MaximumIdentities = e.MaximumIdentities }); } diff --git a/Shuttle.Access.Tests.Integration/DataAccess.Sql/PermissionQueryFixture.cs b/Shuttle.Access.Tests.Integration/DataAccess.Sql/PermissionQueryFixture.cs index bf36774c..86a923c4 100644 --- a/Shuttle.Access.Tests.Integration/DataAccess.Sql/PermissionQueryFixture.cs +++ b/Shuttle.Access.Tests.Integration/DataAccess.Sql/PermissionQueryFixture.cs @@ -1,5 +1,5 @@ -using NUnit.Framework; -using System.Transactions; +using System.Transactions; +using NUnit.Framework; namespace Shuttle.Access.Tests.Integration.DataAccess.Sql; diff --git a/Shuttle.Access.Tests.Integration/DataAccess.Sql/RoleQueryFixture.cs b/Shuttle.Access.Tests.Integration/DataAccess.Sql/RoleQueryFixture.cs index aff67280..f1f7fdd9 100644 --- a/Shuttle.Access.Tests.Integration/DataAccess.Sql/RoleQueryFixture.cs +++ b/Shuttle.Access.Tests.Integration/DataAccess.Sql/RoleQueryFixture.cs @@ -1,5 +1,5 @@ -using NUnit.Framework; -using System.Transactions; +using System.Transactions; +using NUnit.Framework; namespace Shuttle.Access.Tests.Integration.DataAccess.Sql; diff --git a/Shuttle.Access.Tests.Integration/FixtureWebApplicationFactory.cs b/Shuttle.Access.Tests.Integration/FixtureWebApplicationFactory.cs index 949db057..016b58d6 100644 --- a/Shuttle.Access.Tests.Integration/FixtureWebApplicationFactory.cs +++ b/Shuttle.Access.Tests.Integration/FixtureWebApplicationFactory.cs @@ -1,10 +1,10 @@ -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Moq; using Shuttle.Access.AspNetCore; +using Shuttle.Access.Query; using Shuttle.Access.WebApi; -using Shuttle.Mediator; using Shuttle.Hopper; +using Shuttle.Mediator; using Shuttle.OAuth; using Shuttle.Recall.SqlServer.Storage; @@ -12,13 +12,14 @@ namespace Shuttle.Access.Tests.Integration; public class FixtureWebApplicationFactory(Action? webHostBuilder = null, bool useMessaging = true) : WebApplicationFactory { + public Mock Bus { get; } = new(); public Mock IdentityQuery { get; } = new(); public Mock Mediator { get; } = new(); public Mock OAuthGrantRepository { get; } = new(); public Mock PermissionQuery { get; } = new(); public Mock RoleQuery { get; } = new(); - public Mock Bus { get; } = new(); public Mock SessionQuery { get; } = new(); + public Mock TenantQuery { get; } = new(); protected override void ConfigureClient(HttpClient client) { @@ -37,13 +38,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var accessOptions = new AccessOptions(); - var session = new Query.Session + var session = new Session { IdentityId = Guid.NewGuid(), IdentityName = "identity-name", Permissions = [new() { Id = Guid.NewGuid(), Name = "*", TenantId = accessOptions.SystemTenantId }], DateRegistered = DateTimeOffset.UtcNow, - ExpiryDate = DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)), + ExpiryDate = DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)) }; builder.ConfigureServices(services => @@ -64,6 +65,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(PermissionQuery.Object); services.AddSingleton(RoleQuery.Object); services.AddSingleton(SessionQuery.Object); + services.AddSingleton(TenantQuery.Object); services.AddSingleton(Bus.Object); // Credential validation is covered by `AccessSessionResolverFixture`. These fixtures exercise the @@ -73,7 +75,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } - private class FixtureSessionResolver(Query.Session session, Guid tenantId) : ISessionResolver + private class FixtureSessionResolver(Session session, Guid tenantId) : ISessionResolver { public Task ResolveAsync(HttpContext httpContext, CancellationToken cancellationToken = default) { @@ -82,4 +84,4 @@ public Task ResolveAsync(HttpContext httpContext, Cance : SessionResolutionResult.Authenticated(session, tenantId, Guid.NewGuid())); } } -} +} \ No newline at end of file diff --git a/Shuttle.Access.Tests.Integration/WebApi.v1/IdentitiesFixture.cs b/Shuttle.Access.Tests.Integration/WebApi.v1/IdentitiesFixture.cs index b9e3248c..61c2b7dc 100644 --- a/Shuttle.Access.Tests.Integration/WebApi.v1/IdentitiesFixture.cs +++ b/Shuttle.Access.Tests.Integration/WebApi.v1/IdentitiesFixture.cs @@ -2,8 +2,11 @@ using Moq; using NUnit.Framework; using Shuttle.Access.Application; -using Shuttle.Access.Messages.v1; -using RegisterIdentity = Shuttle.Access.Application.RegisterIdentity; +using ActivateIdentity = Shuttle.Access.Messages.v1.ActivateIdentity; +using RegisterIdentity = Shuttle.Access.Messages.v1.RegisterIdentity; +using RemoveIdentity = Shuttle.Access.Messages.v1.RemoveIdentity; +using SetIdentityRoleStatus = Shuttle.Access.Messages.v1.SetIdentityRoleStatus; +using SetIdentityTenantStatus = Shuttle.Access.Messages.v1.SetIdentityTenantStatus; namespace Shuttle.Access.Tests.Integration.WebApi.v1; @@ -55,7 +58,7 @@ public async Task Should_be_able_to_activate_identity() Assert.That(response.IsSuccessStatusCode, Is.True); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); - factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null), Times.Once); + factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null), Times.Once); } [Test] @@ -87,7 +90,7 @@ public async Task Should_be_able_to_delete_identity_async() var factory = new FixtureWebApplicationFactory(); - factory.Bus.Setup(m => m.SendAsync(It.Is(message => message.Id.Equals(id)), null)).Verifiable(); + factory.Bus.Setup(m => m.SendAsync(It.Is(message => message.Id.Equals(id)), null)).Verifiable(); var response = await factory.GetAccessClient().Identities.DeleteAsync(id); @@ -212,7 +215,7 @@ public async Task Should_be_able_to_register_identity_async() { var factory = new FixtureWebApplicationFactory(); - factory.Bus.Setup(m => m.SendAsync(It.IsAny(), null, It.IsAny())); + factory.Bus.Setup(m => m.SendAsync(It.IsAny(), null, It.IsAny())); var response = await factory.GetAccessClient().Identities.RegisterAsync(new() { @@ -223,7 +226,7 @@ public async Task Should_be_able_to_register_identity_async() Assert.That(response.IsSuccessStatusCode, Is.True); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); - factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null, It.IsAny()), Times.Once); + factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null, It.IsAny()), Times.Once); } [Test] @@ -312,8 +315,8 @@ public async Task Should_be_able_to_set_identity_role_status_async() TenantId = tenantId } ]); - - factory.Bus.Setup(m => m.SendAsync(It.Is(message => message.RoleId.Equals(roleId)), null, It.IsAny())).Verifiable(); + + factory.Bus.Setup(m => m.SendAsync(It.Is(message => message.RoleId.Equals(roleId)), null, It.IsAny())).Verifiable(); var response = await factory.GetAccessClient().Identities.SetRoleStatusAsync(identity.Id, roleId, new() { @@ -330,13 +333,25 @@ public async Task Should_be_able_to_set_identity_role_status_async() [Test] public async Task Should_be_able_to_set_identity_tenant_status_async() { + var identity = CreateIdentity(); var tenantId = Guid.NewGuid(); var factory = new FixtureWebApplicationFactory(); - factory.Bus.Setup(m => m.SendAsync(It.Is(message => message.TenantId.Equals(tenantId)), null)).Verifiable(); + factory.IdentityQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([identity]); + + factory.TenantQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new() + { + Id = tenantId, + Name = "tenant", + MaximumIdentities = 0 + } + ]); + + factory.Bus.Setup(m => m.SendAsync(It.Is(message => message.TenantId.Equals(tenantId)), null, It.IsAny())).Verifiable(); - var response = await factory.GetAccessClient().Identities.SetTenantAsync(Guid.NewGuid(), tenantId, new() + var response = await factory.GetAccessClient().Identities.SetTenantAsync(identity.Id, tenantId, new() { Active = true }); @@ -348,6 +363,77 @@ public async Task Should_be_able_to_set_identity_tenant_status_async() factory.Bus.VerifyAll(); } + [Test] + public async Task Should_not_be_able_to_set_identity_tenant_status_when_tenant_is_at_maximum_identities_async() + { + var identity = CreateIdentity(); + var tenantId = Guid.NewGuid(); + + var factory = new FixtureWebApplicationFactory(); + + factory.IdentityQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([identity]); + + factory.IdentityQuery.Setup(m => m.CountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(2); + + factory.TenantQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new() + { + Id = tenantId, + Name = "tenant", + MaximumIdentities = 2 + } + ]); + + var response = await factory.GetAccessClient().Identities.SetTenantAsync(identity.Id, tenantId, new() + { + Active = true + }); + + Assert.That(response, Is.Not.Null); + Assert.That(response.IsSuccessStatusCode, Is.False); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null, It.IsAny()), Times.Never); + } + + [Test] + public async Task Should_be_able_to_set_identity_tenant_status_when_identity_is_already_in_the_tenant_at_maximum_identities_async() + { + var tenantId = Guid.NewGuid(); + + var identity = CreateIdentity(); + + identity.Tenants = [new() { Id = tenantId }]; + + var factory = new FixtureWebApplicationFactory(); + + factory.IdentityQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([identity]); + + factory.TenantQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new() + { + Id = tenantId, + Name = "tenant", + MaximumIdentities = 1 + } + ]); + + factory.Bus.Setup(m => m.SendAsync(It.Is(message => message.TenantId.Equals(tenantId)), null, It.IsAny())).Verifiable(); + + var response = await factory.GetAccessClient().Identities.SetTenantAsync(identity.Id, tenantId, new() + { + Active = true + }); + + Assert.That(response, Is.Not.Null); + Assert.That(response.IsSuccessStatusCode, Is.True); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + + factory.Bus.VerifyAll(); + + factory.IdentityQuery.Verify(m => m.CountAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task Should_not_be_able_to_activate_unknown_identity() { @@ -410,6 +496,53 @@ public async Task Should_not_be_able_to_change_password_when_no_session_token_is factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null), Times.Never); } + [Test] + public async Task Should_not_be_able_to_deactivate_last_administrator_async() + { + var tenantId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + + var identity = CreateIdentity(); + + identity.Tenants = + [ + new() + { + Id = tenantId + } + ]; + + var factory = new FixtureWebApplicationFactory(); + + factory.IdentityQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).Returns(Task.FromResult( + new List + { + identity + }.AsEnumerable())); + + factory.RoleQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new() + { + Id = roleId, + TenantId = tenantId + } + ]); + + factory.Mediator.Setup(m => m.SendAsync(It.IsAny(), It.IsAny())) + .Callback((message, _) => ((ReviewIdentityRoleRemoval)message).LastAdministrator()); + + var response = await factory.GetAccessClient().Identities.SetRoleStatusAsync(Guid.NewGuid(), roleId, new() + { + Active = false + }); + + Assert.That(response, Is.Not.Null); + Assert.That(response.IsSuccessStatusCode, Is.False); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null), Times.Never); + } + [Test] public async Task Should_not_be_able_to_get_password_reset_token_when_mediator_call_fails_async() { @@ -501,51 +634,4 @@ public async Task Should_not_be_able_to_reset_password_when_no_session_token_is_ factory.Mediator.Verify(m => m.SendAsync(It.IsAny(), CancellationToken.None), Times.Never); factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null), Times.Never); } - - [Test] - public async Task Should_not_be_able_to_deactivate_last_administrator_async() - { - var tenantId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - - var identity = CreateIdentity(); - - identity.Tenants = - [ - new() - { - Id = tenantId - } - ]; - - var factory = new FixtureWebApplicationFactory(); - - factory.IdentityQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).Returns(Task.FromResult( - new List - { - identity - }.AsEnumerable())); - - factory.RoleQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ - new() - { - Id = roleId, - TenantId = tenantId - } - ]); - - factory.Mediator.Setup(m => m.SendAsync(It.IsAny(), It.IsAny())) - .Callback((message, _) => ((ReviewIdentityRoleRemoval)message).LastAdministrator()); - - var response = await factory.GetAccessClient().Identities.SetRoleStatusAsync(Guid.NewGuid(), roleId, new() - { - Active = false - }); - - Assert.That(response, Is.Not.Null); - Assert.That(response.IsSuccessStatusCode, Is.False); - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); - - factory.Bus.Verify(m => m.SendAsync(It.IsAny(), null), Times.Never); - } } \ No newline at end of file diff --git a/Shuttle.Access.Tests.Integration/WebApi.v1/RolesFixture.cs b/Shuttle.Access.Tests.Integration/WebApi.v1/RolesFixture.cs index a76b5b04..daa1a995 100644 --- a/Shuttle.Access.Tests.Integration/WebApi.v1/RolesFixture.cs +++ b/Shuttle.Access.Tests.Integration/WebApi.v1/RolesFixture.cs @@ -19,14 +19,14 @@ private static Query.Role CreateRole() new() { Id = Guid.NewGuid(), - Name = "system://permission-a", - Status = PermissionStatus.Active + Name = "system://permission-a", + Status = PermissionStatus.Active }, new() { Id = Guid.NewGuid(), - Name = "system://permission-b", - Status = PermissionStatus.Active + Name = "system://permission-b", + Status = PermissionStatus.Active } ] }; diff --git a/Shuttle.Access.Tests/Participants/SetIdentityTenantStatusParticipantFixture.cs b/Shuttle.Access.Tests/Participants/SetIdentityTenantStatusParticipantFixture.cs new file mode 100644 index 00000000..40b74fca --- /dev/null +++ b/Shuttle.Access.Tests/Participants/SetIdentityTenantStatusParticipantFixture.cs @@ -0,0 +1,118 @@ +using Moq; +using NUnit.Framework; +using Shuttle.Access.Application; +using Shuttle.Recall; +using TenantAdded = Shuttle.Access.Events.Identity.v1.TenantAdded; + +namespace Shuttle.Access.Tests.Participants; + +[TestFixture] +public class SetIdentityTenantStatusParticipantFixture +{ + [Test] + public async Task Should_be_able_to_add_identity_to_tenant_when_under_maximum_async() + { + var identityId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + + var eventStore = new FixtureEventStore(); + var tenantQuery = new Mock(); + var identityQuery = new Mock(); + + tenantQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new() + { + Id = tenantId, + Name = "tenant", + MaximumIdentities = 2 + } + ]); + + identityQuery.Setup(m => m.CountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + + var eventStream = await eventStore.GetAsync(identityId); + + eventStream.Add(new Events.Identity.v1.Registered + { + Name = "identity" + }); + + eventStream.Commit(); + + var participant = new SetIdentityTenantStatusParticipant(eventStore, tenantQuery.Object, identityQuery.Object); + + var setIdentityTenantStatus = new SetIdentityTenantStatus(identityId, tenantId, true, Guid.NewGuid(), "system"); + + await participant.HandleAsync(setIdentityTenantStatus); + + Assert.That(eventStream.Count, Is.EqualTo(2)); + Assert.That(((TenantAdded)eventStream.GetEvents(EventStream.EventRegistrationType.All).Last().Event).TenantId, Is.EqualTo(tenantId)); + } + + [Test] + public void Should_not_be_able_to_add_identity_to_tenant_when_at_maximum() + { + var identityId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + + var eventStore = new FixtureEventStore(); + var tenantQuery = new Mock(); + var identityQuery = new Mock(); + + tenantQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new() + { + Id = tenantId, + Name = "tenant", + MaximumIdentities = 2 + } + ]); + + identityQuery.Setup(m => m.CountAsync(It.IsAny(), It.IsAny())).ReturnsAsync(2); + + var participant = new SetIdentityTenantStatusParticipant(eventStore, tenantQuery.Object, identityQuery.Object); + + var setIdentityTenantStatus = new SetIdentityTenantStatus(identityId, tenantId, true, Guid.NewGuid(), "system"); + + Assert.ThrowsAsync(async () => await participant.HandleAsync(setIdentityTenantStatus)); + } + + [Test] + public async Task Should_be_able_to_add_identity_to_tenant_with_unlimited_maximum_async() + { + var identityId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + + var eventStore = new FixtureEventStore(); + var tenantQuery = new Mock(); + var identityQuery = new Mock(); + + tenantQuery.Setup(m => m.SearchAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new() + { + Id = tenantId, + Name = "tenant", + MaximumIdentities = 0 + } + ]); + + var eventStream = await eventStore.GetAsync(identityId); + + eventStream.Add(new Events.Identity.v1.Registered + { + Name = "identity" + }); + + eventStream.Commit(); + + var participant = new SetIdentityTenantStatusParticipant(eventStore, tenantQuery.Object, identityQuery.Object); + + var setIdentityTenantStatus = new SetIdentityTenantStatus(identityId, tenantId, true, Guid.NewGuid(), "system"); + + await participant.HandleAsync(setIdentityTenantStatus); + + Assert.That(eventStream.Count, Is.EqualTo(2)); + + identityQuery.Verify(m => m.CountAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Shuttle.Access.WebApi.Contracts/v1/RegisterTenant.cs b/Shuttle.Access.WebApi.Contracts/v1/RegisterTenant.cs index 8dc527fc..403e5617 100644 --- a/Shuttle.Access.WebApi.Contracts/v1/RegisterTenant.cs +++ b/Shuttle.Access.WebApi.Contracts/v1/RegisterTenant.cs @@ -7,5 +7,6 @@ public class RegisterTenant public string LogoSvg { get; set; } = string.Empty; public string LogoUrl { get; set; } = string.Empty; public int Status { get; set; } = 1; + public int MaximumIdentities { get; set; } public string AdministratorIdentityName { get; set; } = string.Empty; } \ No newline at end of file diff --git a/Shuttle.Access.WebApi.Contracts/v1/Tenant.cs b/Shuttle.Access.WebApi.Contracts/v1/Tenant.cs index 597b74ce..957fe25a 100644 --- a/Shuttle.Access.WebApi.Contracts/v1/Tenant.cs +++ b/Shuttle.Access.WebApi.Contracts/v1/Tenant.cs @@ -8,6 +8,7 @@ public class Tenant public string StatusName { get; set; } = string.Empty; public string LogoSvg { get; set; } = string.Empty; public string LogoUrl { get; set; } = string.Empty; + public int MaximumIdentities { get; set; } public class Specification { diff --git a/Shuttle.Access.WebApi/Endpoints/IdentityEndpoints.cs b/Shuttle.Access.WebApi/Endpoints/IdentityEndpoints.cs index 3d1744f3..17babbbb 100644 --- a/Shuttle.Access.WebApi/Endpoints/IdentityEndpoints.cs +++ b/Shuttle.Access.WebApi/Endpoints/IdentityEndpoints.cs @@ -294,8 +294,23 @@ await messageDispatcher.DispatchAsync( return Results.Accepted(); } - private static async Task PatchTenantStatus(Guid id, Guid tenantId, [FromBody] SetActiveStatus message, ISessionContext sessionContext, MessageDispatcher messageDispatcher) + private static async Task PatchTenantStatus(Guid id, Guid tenantId, [FromBody] SetActiveStatus message, ISessionContext sessionContext, ITenantQuery tenantQuery, IIdentityQuery identityQuery, MessageDispatcher messageDispatcher, CancellationToken cancellationToken) { + if (message.Active) + { + var identity = (await identityQuery.FindAsync(new Query.Identity.Specification().AddId(id).IncludeTenants(), cancellationToken: cancellationToken)).GuardAgainstRecordNotFound(id); + + if (identity.Tenants.All(e => e.Id != tenantId)) + { + var tenant = (await tenantQuery.FindAsync(new Query.Tenant.Specification().AddId(tenantId), cancellationToken: cancellationToken)).GuardAgainstRecordNotFound(tenantId); + + if (tenant.MaximumIdentities > 0 && await identityQuery.CountAsync(new Query.Identity.Specification().WithTenantId(tenantId), cancellationToken) >= tenant.MaximumIdentities) + { + return Results.BadRequest($"Tenant '{tenant.Name}' has reached its maximum number of identities ({tenant.MaximumIdentities})."); + } + } + } + await messageDispatcher.DispatchAsync( () => sessionContext.Audit(new Messages.v1.SetIdentityTenantStatus { @@ -303,7 +318,8 @@ await messageDispatcher.DispatchAsync( TenantId = tenantId, Active = message.Active }), - () => new Application.SetIdentityTenantStatus(id, tenantId, message.Active, sessionContext.TenantId, sessionContext.Session.IdentityName)); + () => new Application.SetIdentityTenantStatus(id, tenantId, message.Active, sessionContext.TenantId, sessionContext.Session.IdentityName), + cancellationToken); return Results.Accepted(); } diff --git a/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs b/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs index 5d26527c..76d66db1 100644 --- a/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs +++ b/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs @@ -38,7 +38,8 @@ private static Contracts.v1.Tenant Map(Query.Tenant tenant) Status = (int)tenant.Status, StatusName = tenant.Status.ToString(), LogoSvg = tenant.LogoSvg, - LogoUrl = tenant.LogoUrl + LogoUrl = tenant.LogoUrl, + MaximumIdentities = tenant.MaximumIdentities }; } @@ -111,6 +112,7 @@ await messageDispatcher.DispatchAsync( Status = message.Status, LogoUrl = message.LogoUrl, LogoSvg = message.LogoSvg, + MaximumIdentities = message.MaximumIdentities, AdministratorIdentityName = message.AdministratorIdentityName, AccessAdministratorRoleId = accessAdministratorRoleId }), @@ -118,6 +120,7 @@ await messageDispatcher.DispatchAsync( { LogoUrl = message.LogoUrl, LogoSvg = message.LogoSvg, + MaximumIdentities = message.MaximumIdentities, AdministratorIdentityName = message.AdministratorIdentityName, AccessAdministratorRoleId = accessAdministratorRoleId }, diff --git a/Shuttle.Access/Events/Tenant.v1/Registered.cs b/Shuttle.Access/Events/Tenant.v1/Registered.cs index 87f5608f..803a1fd2 100644 --- a/Shuttle.Access/Events/Tenant.v1/Registered.cs +++ b/Shuttle.Access/Events/Tenant.v1/Registered.cs @@ -6,4 +6,5 @@ public class Registered public string LogoSvg { get; set; } = string.Empty; public string LogoUrl { get; set; } = string.Empty; public int Status { get; set; } + public int MaximumIdentities { get; set; } } \ No newline at end of file diff --git a/Shuttle.Access/Query/Tenant.cs b/Shuttle.Access/Query/Tenant.cs index 51de33f0..8181d030 100644 --- a/Shuttle.Access/Query/Tenant.cs +++ b/Shuttle.Access/Query/Tenant.cs @@ -7,6 +7,7 @@ public class Tenant public TenantStatus Status { get; set; } public string LogoSvg { get; set; } = string.Empty; public string LogoUrl { get; set; } = string.Empty; + public int MaximumIdentities { get; set; } public class Specification : Specification { diff --git a/Shuttle.Access/Resources.Designer.cs b/Shuttle.Access/Resources.Designer.cs index 7d8c11e7..14ec9f98 100644 --- a/Shuttle.Access/Resources.Designer.cs +++ b/Shuttle.Access/Resources.Designer.cs @@ -312,6 +312,15 @@ public static string SessionTokenExpiredException { } } + /// + /// Looks up a localized string similar to Tenant '{0}' has reached its maximum number of identities ({1}).. + /// + public static string TenantMaximumIdentitiesExceededException { + get { + return ResourceManager.GetString("TenantMaximumIdentitiesExceededException", resourceCulture); + } + } + /// /// Looks up a localized string similar to Could not find an identity with name '{0}'.. /// diff --git a/Shuttle.Access/Resources.resx b/Shuttle.Access/Resources.resx index bb2f3ded..5ba95c9d 100644 --- a/Shuttle.Access/Resources.resx +++ b/Shuttle.Access/Resources.resx @@ -211,6 +211,10 @@ The session token has expired. Please log in again. + + Tenant '{0}' has reached its maximum number of identities ({1}). + 0 = tenant name, 1 = maximum identities + Could not find an identity with name '{0}'. 0 = identity name diff --git a/Shuttle.Access/Tenant.cs b/Shuttle.Access/Tenant.cs index cf16ca02..1d377c30 100644 --- a/Shuttle.Access/Tenant.cs +++ b/Shuttle.Access/Tenant.cs @@ -13,6 +13,7 @@ public class Tenant { public string LogoSvg { get; private set; } = string.Empty; public string LogoUrl { get; private set; } = string.Empty; + public int MaximumIdentities { get; private set; } public string Name { get; private set; } = string.Empty; public TenantStatus Status { get; private set; } @@ -28,6 +29,7 @@ private Registered On(Registered registered) Name = registered.Name; LogoSvg = registered.LogoSvg; LogoUrl = registered.LogoUrl; + MaximumIdentities = registered.MaximumIdentities; return registered; } @@ -48,14 +50,15 @@ private Removed On(Removed removed) return removed; } - public Registered Register(string name, int status, string logoSvg, string logoUrl) + public Registered Register(string name, int status, string logoSvg, string logoUrl, int maximumIdentities) { return On(new Registered { Name = Guard.AgainstEmpty(name), LogoSvg = logoSvg, LogoUrl = logoUrl, - Status = status + Status = status, + MaximumIdentities = maximumIdentities }); } From 0b3155a8733d0939d1cb7138edb2c012c718a2dc Mon Sep 17 00:00:00 2001 From: Eben Date: Sun, 23 Aug 2026 13:30:51 +0200 Subject: [PATCH 2/2] Tenant changes. --- .../Messages/SetTenantLogoSvg.cs | 12 +++ .../Messages/SetTenantLogoUrl.cs | 12 +++ .../Messages/SetTenantMaximumIdentities.cs | 12 +++ .../Messages/SetTenantName.cs | 12 +++ .../SetTenantLogoSvgParticipant.cs | 25 +++++ .../SetTenantLogoUrlParticipant.cs | 25 +++++ .../SetTenantMaximumIdentitiesParticipant.cs | 25 +++++ .../SetTenantNameParticipant.cs | 36 +++++++ .../v1/SetTenantLogoSvg.cs | 7 ++ .../v1/SetTenantLogoUrl.cs | 7 ++ .../v1/SetTenantMaximumIdentities.cs | 7 ++ Shuttle.Access.Messages/v1/SetTenantName.cs | 7 ++ .../v1/TenantLogoSvgSet.cs | 7 ++ .../v1/TenantLogoUrlSet.cs | 7 ++ .../v1/TenantMaximumIdentitiesSet.cs | 7 ++ Shuttle.Access.Messages/v1/TenantNameSet.cs | 7 ++ Shuttle.Access.RestClient/v1/ITenantApi.cs | 12 +++ .../v1/EventHandlers/TenantHandler.cs | 96 ++++++++++++++++++- .../SetTenantLogoSvgHandler.cs | 15 +++ .../SetTenantLogoUrlHandler.cs | 15 +++ .../SetTenantMaximumIdentitiesHandler.cs | 15 +++ .../MessageHandlers/SetTenantNameHandler.cs | 15 +++ .../v1/SetLogoSvg.cs | 6 ++ .../v1/SetLogoUrl.cs | 6 ++ .../v1/SetMaximumIdentities.cs | 6 ++ .../Endpoints/TenantEndpoints.cs | 80 ++++++++++++++++ Shuttle.Access/Events/Tenant.v1/LogoSvgSet.cs | 6 ++ Shuttle.Access/Events/Tenant.v1/LogoUrlSet.cs | 6 ++ .../Events/Tenant.v1/MaximumIdentitiesSet.cs | 6 ++ Shuttle.Access/Events/Tenant.v1/NameSet.cs | 6 ++ Shuttle.Access/Tenant.cs | 88 +++++++++++++++++ 31 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 Shuttle.Access.Application/Messages/SetTenantLogoSvg.cs create mode 100644 Shuttle.Access.Application/Messages/SetTenantLogoUrl.cs create mode 100644 Shuttle.Access.Application/Messages/SetTenantMaximumIdentities.cs create mode 100644 Shuttle.Access.Application/Messages/SetTenantName.cs create mode 100644 Shuttle.Access.Application/SetTenantLogoSvgParticipant.cs create mode 100644 Shuttle.Access.Application/SetTenantLogoUrlParticipant.cs create mode 100644 Shuttle.Access.Application/SetTenantMaximumIdentitiesParticipant.cs create mode 100644 Shuttle.Access.Application/SetTenantNameParticipant.cs create mode 100644 Shuttle.Access.Messages/v1/SetTenantLogoSvg.cs create mode 100644 Shuttle.Access.Messages/v1/SetTenantLogoUrl.cs create mode 100644 Shuttle.Access.Messages/v1/SetTenantMaximumIdentities.cs create mode 100644 Shuttle.Access.Messages/v1/SetTenantName.cs create mode 100644 Shuttle.Access.Messages/v1/TenantLogoSvgSet.cs create mode 100644 Shuttle.Access.Messages/v1/TenantLogoUrlSet.cs create mode 100644 Shuttle.Access.Messages/v1/TenantMaximumIdentitiesSet.cs create mode 100644 Shuttle.Access.Messages/v1/TenantNameSet.cs create mode 100644 Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoSvgHandler.cs create mode 100644 Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoUrlHandler.cs create mode 100644 Shuttle.Access.Server/v1/MessageHandlers/SetTenantMaximumIdentitiesHandler.cs create mode 100644 Shuttle.Access.Server/v1/MessageHandlers/SetTenantNameHandler.cs create mode 100644 Shuttle.Access.WebApi.Contracts/v1/SetLogoSvg.cs create mode 100644 Shuttle.Access.WebApi.Contracts/v1/SetLogoUrl.cs create mode 100644 Shuttle.Access.WebApi.Contracts/v1/SetMaximumIdentities.cs create mode 100644 Shuttle.Access/Events/Tenant.v1/LogoSvgSet.cs create mode 100644 Shuttle.Access/Events/Tenant.v1/LogoUrlSet.cs create mode 100644 Shuttle.Access/Events/Tenant.v1/MaximumIdentitiesSet.cs create mode 100644 Shuttle.Access/Events/Tenant.v1/NameSet.cs diff --git a/Shuttle.Access.Application/Messages/SetTenantLogoSvg.cs b/Shuttle.Access.Application/Messages/SetTenantLogoSvg.cs new file mode 100644 index 00000000..9f522120 --- /dev/null +++ b/Shuttle.Access.Application/Messages/SetTenantLogoSvg.cs @@ -0,0 +1,12 @@ +using Shuttle.Contract; + +namespace Shuttle.Access.Application; + +public class SetTenantLogoSvg(Guid id, string logoSvg, Guid auditTenantId, string auditIdentityName) + : IAuditInformation +{ + public Guid Id { get; } = Guard.AgainstEmpty(id); + public string LogoSvg { get; } = logoSvg; + public Guid AuditTenantId { get; } = Guard.AgainstEmpty(auditTenantId); + public string AuditIdentityName { get; } = Guard.AgainstEmpty(auditIdentityName); +} diff --git a/Shuttle.Access.Application/Messages/SetTenantLogoUrl.cs b/Shuttle.Access.Application/Messages/SetTenantLogoUrl.cs new file mode 100644 index 00000000..9ab81e37 --- /dev/null +++ b/Shuttle.Access.Application/Messages/SetTenantLogoUrl.cs @@ -0,0 +1,12 @@ +using Shuttle.Contract; + +namespace Shuttle.Access.Application; + +public class SetTenantLogoUrl(Guid id, string logoUrl, Guid auditTenantId, string auditIdentityName) + : IAuditInformation +{ + public Guid Id { get; } = Guard.AgainstEmpty(id); + public string LogoUrl { get; } = logoUrl; + public Guid AuditTenantId { get; } = Guard.AgainstEmpty(auditTenantId); + public string AuditIdentityName { get; } = Guard.AgainstEmpty(auditIdentityName); +} diff --git a/Shuttle.Access.Application/Messages/SetTenantMaximumIdentities.cs b/Shuttle.Access.Application/Messages/SetTenantMaximumIdentities.cs new file mode 100644 index 00000000..573823d2 --- /dev/null +++ b/Shuttle.Access.Application/Messages/SetTenantMaximumIdentities.cs @@ -0,0 +1,12 @@ +using Shuttle.Contract; + +namespace Shuttle.Access.Application; + +public class SetTenantMaximumIdentities(Guid id, int maximumIdentities, Guid auditTenantId, string auditIdentityName) + : IAuditInformation +{ + public Guid Id { get; } = Guard.AgainstEmpty(id); + public int MaximumIdentities { get; } = maximumIdentities; + public Guid AuditTenantId { get; } = Guard.AgainstEmpty(auditTenantId); + public string AuditIdentityName { get; } = Guard.AgainstEmpty(auditIdentityName); +} diff --git a/Shuttle.Access.Application/Messages/SetTenantName.cs b/Shuttle.Access.Application/Messages/SetTenantName.cs new file mode 100644 index 00000000..1fa1b2f7 --- /dev/null +++ b/Shuttle.Access.Application/Messages/SetTenantName.cs @@ -0,0 +1,12 @@ +using Shuttle.Contract; + +namespace Shuttle.Access.Application; + +public class SetTenantName(Guid id, string name, Guid auditTenantId, string auditIdentityName) + : IAuditInformation +{ + public Guid Id { get; } = Guard.AgainstEmpty(id); + public string Name { get; } = name; + public Guid AuditTenantId { get; } = Guard.AgainstEmpty(auditTenantId); + public string AuditIdentityName { get; } = Guard.AgainstEmpty(auditIdentityName); +} diff --git a/Shuttle.Access.Application/SetTenantLogoSvgParticipant.cs b/Shuttle.Access.Application/SetTenantLogoSvgParticipant.cs new file mode 100644 index 00000000..f0729d7d --- /dev/null +++ b/Shuttle.Access.Application/SetTenantLogoSvgParticipant.cs @@ -0,0 +1,25 @@ +using Shuttle.Contract; +using Shuttle.Mediator; +using Shuttle.Recall; + +namespace Shuttle.Access.Application; + +public class SetTenantLogoSvgParticipant(IEventStore eventStore) : IParticipant +{ + public async Task HandleAsync(SetTenantLogoSvg message, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(message); + + var stream = (await eventStore.GetAsync(message.Id, cancellationToken)).MustHaveEvents(); + var tenant = stream.Get(); + + if (tenant.LogoSvg == message.LogoSvg) + { + return; + } + + stream.Add(tenant.SetLogoSvg(message.LogoSvg)); + + await eventStore.SaveAsync(stream, builder => builder.Audit(message), cancellationToken); + } +} diff --git a/Shuttle.Access.Application/SetTenantLogoUrlParticipant.cs b/Shuttle.Access.Application/SetTenantLogoUrlParticipant.cs new file mode 100644 index 00000000..1682dc93 --- /dev/null +++ b/Shuttle.Access.Application/SetTenantLogoUrlParticipant.cs @@ -0,0 +1,25 @@ +using Shuttle.Contract; +using Shuttle.Mediator; +using Shuttle.Recall; + +namespace Shuttle.Access.Application; + +public class SetTenantLogoUrlParticipant(IEventStore eventStore) : IParticipant +{ + public async Task HandleAsync(SetTenantLogoUrl message, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(message); + + var stream = (await eventStore.GetAsync(message.Id, cancellationToken)).MustHaveEvents(); + var tenant = stream.Get(); + + if (tenant.LogoUrl == message.LogoUrl) + { + return; + } + + stream.Add(tenant.SetLogoUrl(message.LogoUrl)); + + await eventStore.SaveAsync(stream, builder => builder.Audit(message), cancellationToken); + } +} diff --git a/Shuttle.Access.Application/SetTenantMaximumIdentitiesParticipant.cs b/Shuttle.Access.Application/SetTenantMaximumIdentitiesParticipant.cs new file mode 100644 index 00000000..ce5631e7 --- /dev/null +++ b/Shuttle.Access.Application/SetTenantMaximumIdentitiesParticipant.cs @@ -0,0 +1,25 @@ +using Shuttle.Contract; +using Shuttle.Mediator; +using Shuttle.Recall; + +namespace Shuttle.Access.Application; + +public class SetTenantMaximumIdentitiesParticipant(IEventStore eventStore) : IParticipant +{ + public async Task HandleAsync(SetTenantMaximumIdentities message, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(message); + + var stream = (await eventStore.GetAsync(message.Id, cancellationToken)).MustHaveEvents(); + var tenant = stream.Get(); + + if (tenant.MaximumIdentities == message.MaximumIdentities) + { + return; + } + + stream.Add(tenant.SetMaximumIdentities(message.MaximumIdentities)); + + await eventStore.SaveAsync(stream, builder => builder.Audit(message), cancellationToken); + } +} diff --git a/Shuttle.Access.Application/SetTenantNameParticipant.cs b/Shuttle.Access.Application/SetTenantNameParticipant.cs new file mode 100644 index 00000000..0a5acea3 --- /dev/null +++ b/Shuttle.Access.Application/SetTenantNameParticipant.cs @@ -0,0 +1,36 @@ +using Shuttle.Contract; +using Shuttle.Mediator; +using Shuttle.Recall; +using Shuttle.Recall.SqlServer.Storage; + +namespace Shuttle.Access.Application; + +public class SetTenantNameParticipant(IEventStore eventStore, IIdKeyRepository idKeyRepository) : IParticipant +{ + public async Task HandleAsync(SetTenantName message, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(message); + + var stream = (await eventStore.GetAsync(message.Id, cancellationToken)).MustHaveEvents(); + var tenant = stream.Get(); + + if (tenant.Name.Equals(message.Name)) + { + return; + } + + var key = Tenant.Key(tenant.Name); + var rekey = Tenant.Key(message.Name); + + if (await idKeyRepository.ContainsAsync(rekey, cancellationToken) || !await idKeyRepository.ContainsAsync(key, cancellationToken)) + { + return; + } + + await idKeyRepository.RekeyAsync(key, rekey, cancellationToken); + + stream.Add(tenant.SetName(message.Name)); + + await eventStore.SaveAsync(stream, builder => builder.Audit(message), cancellationToken); + } +} diff --git a/Shuttle.Access.Messages/v1/SetTenantLogoSvg.cs b/Shuttle.Access.Messages/v1/SetTenantLogoSvg.cs new file mode 100644 index 00000000..7dfd385b --- /dev/null +++ b/Shuttle.Access.Messages/v1/SetTenantLogoSvg.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class SetTenantLogoSvg : AuditMessage +{ + public Guid Id { get; set; } + public string LogoSvg { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.Messages/v1/SetTenantLogoUrl.cs b/Shuttle.Access.Messages/v1/SetTenantLogoUrl.cs new file mode 100644 index 00000000..aae6c6ad --- /dev/null +++ b/Shuttle.Access.Messages/v1/SetTenantLogoUrl.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class SetTenantLogoUrl : AuditMessage +{ + public Guid Id { get; set; } + public string LogoUrl { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.Messages/v1/SetTenantMaximumIdentities.cs b/Shuttle.Access.Messages/v1/SetTenantMaximumIdentities.cs new file mode 100644 index 00000000..4b6b65c7 --- /dev/null +++ b/Shuttle.Access.Messages/v1/SetTenantMaximumIdentities.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class SetTenantMaximumIdentities : AuditMessage +{ + public Guid Id { get; set; } + public int MaximumIdentities { get; set; } +} diff --git a/Shuttle.Access.Messages/v1/SetTenantName.cs b/Shuttle.Access.Messages/v1/SetTenantName.cs new file mode 100644 index 00000000..66eab7a0 --- /dev/null +++ b/Shuttle.Access.Messages/v1/SetTenantName.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class SetTenantName : AuditMessage +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.Messages/v1/TenantLogoSvgSet.cs b/Shuttle.Access.Messages/v1/TenantLogoSvgSet.cs new file mode 100644 index 00000000..866b6b45 --- /dev/null +++ b/Shuttle.Access.Messages/v1/TenantLogoSvgSet.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class TenantLogoSvgSet +{ + public Guid Id { get; set; } + public string LogoSvg { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.Messages/v1/TenantLogoUrlSet.cs b/Shuttle.Access.Messages/v1/TenantLogoUrlSet.cs new file mode 100644 index 00000000..b7b9cc5d --- /dev/null +++ b/Shuttle.Access.Messages/v1/TenantLogoUrlSet.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class TenantLogoUrlSet +{ + public Guid Id { get; set; } + public string LogoUrl { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.Messages/v1/TenantMaximumIdentitiesSet.cs b/Shuttle.Access.Messages/v1/TenantMaximumIdentitiesSet.cs new file mode 100644 index 00000000..67a269a2 --- /dev/null +++ b/Shuttle.Access.Messages/v1/TenantMaximumIdentitiesSet.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class TenantMaximumIdentitiesSet +{ + public Guid Id { get; set; } + public int MaximumIdentities { get; set; } +} diff --git a/Shuttle.Access.Messages/v1/TenantNameSet.cs b/Shuttle.Access.Messages/v1/TenantNameSet.cs new file mode 100644 index 00000000..435659d5 --- /dev/null +++ b/Shuttle.Access.Messages/v1/TenantNameSet.cs @@ -0,0 +1,7 @@ +namespace Shuttle.Access.Messages.v1; + +public class TenantNameSet +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.RestClient/v1/ITenantApi.cs b/Shuttle.Access.RestClient/v1/ITenantApi.cs index 83669207..5849be05 100644 --- a/Shuttle.Access.RestClient/v1/ITenantApi.cs +++ b/Shuttle.Access.RestClient/v1/ITenantApi.cs @@ -18,4 +18,16 @@ public interface ITenantApi [Patch("/v1/tenants/{id}/status")] Task SetStatusAsync(Guid id, WebApi.Contracts.v1.SetStatus message, CancellationToken cancellationToken = default); + + [Patch("/v1/tenants/{id}/name")] + Task SetNameAsync(Guid id, WebApi.Contracts.v1.SetName message, CancellationToken cancellationToken = default); + + [Patch("/v1/tenants/{id}/logo-url")] + Task SetLogoUrlAsync(Guid id, WebApi.Contracts.v1.SetLogoUrl message, CancellationToken cancellationToken = default); + + [Patch("/v1/tenants/{id}/logo-svg")] + Task SetLogoSvgAsync(Guid id, WebApi.Contracts.v1.SetLogoSvg message, CancellationToken cancellationToken = default); + + [Patch("/v1/tenants/{id}/maximum-identities")] + Task SetMaximumIdentitiesAsync(Guid id, WebApi.Contracts.v1.SetMaximumIdentities message, CancellationToken cancellationToken = default); } diff --git a/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs b/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs index 42943412..e130d084 100644 --- a/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs +++ b/Shuttle.Access.Server/v1/EventHandlers/TenantHandler.cs @@ -8,10 +8,14 @@ using Shuttle.Recall; namespace Shuttle.Access.Server.v1.EventHandlers; -public class TenantHandler(ILogger logger, AccessDbContext accessDbContext, IBus bus) : +public class TenantHandler(ILogger logger, AccessDbContext accessDbContext, IBus bus) : IEventHandler, IEventHandler, - IEventHandler + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler { private readonly AccessDbContext _accessDbContext = Guard.AgainstNull(accessDbContext); private readonly ILogger _logger = Guard.AgainstNull(logger); @@ -72,6 +76,94 @@ await _bus.PublishAsync(new TenantStatusSet }, cancellationToken: cancellationToken); } + public async Task HandleAsync(IEventHandlerContext context, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(context); + + var model = await _accessDbContext.Tenants.FindAsync([context.PrimitiveEvent.Id], cancellationToken: cancellationToken); + + if (model == null) + { + return; + } + + model.Name = context.Event.Name; + + await _accessDbContext.SaveChangesAsync(cancellationToken); + + await _bus.PublishAsync(new TenantNameSet + { + Id = model.Id, + Name = model.Name + }, cancellationToken: cancellationToken); + } + + public async Task HandleAsync(IEventHandlerContext context, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(context); + + var model = await _accessDbContext.Tenants.FindAsync([context.PrimitiveEvent.Id], cancellationToken: cancellationToken); + + if (model == null) + { + return; + } + + model.LogoUrl = context.Event.LogoUrl; + + await _accessDbContext.SaveChangesAsync(cancellationToken); + + await _bus.PublishAsync(new TenantLogoUrlSet + { + Id = model.Id, + LogoUrl = model.LogoUrl + }, cancellationToken: cancellationToken); + } + + public async Task HandleAsync(IEventHandlerContext context, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(context); + + var model = await _accessDbContext.Tenants.FindAsync([context.PrimitiveEvent.Id], cancellationToken: cancellationToken); + + if (model == null) + { + return; + } + + model.LogoSvg = context.Event.LogoSvg; + + await _accessDbContext.SaveChangesAsync(cancellationToken); + + await _bus.PublishAsync(new TenantLogoSvgSet + { + Id = model.Id, + LogoSvg = model.LogoSvg + }, cancellationToken: cancellationToken); + } + + public async Task HandleAsync(IEventHandlerContext context, CancellationToken cancellationToken = default) + { + Guard.AgainstNull(context); + + var model = await _accessDbContext.Tenants.FindAsync([context.PrimitiveEvent.Id], cancellationToken: cancellationToken); + + if (model == null) + { + return; + } + + model.MaximumIdentities = context.Event.MaximumIdentities; + + await _accessDbContext.SaveChangesAsync(cancellationToken); + + await _bus.PublishAsync(new TenantMaximumIdentitiesSet + { + Id = model.Id, + MaximumIdentities = model.MaximumIdentities + }, cancellationToken: cancellationToken); + } + public async Task HandleAsync(IEventHandlerContext context, CancellationToken cancellationToken = default) { Guard.AgainstNull(context); diff --git a/Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoSvgHandler.cs b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoSvgHandler.cs new file mode 100644 index 00000000..02cca160 --- /dev/null +++ b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoSvgHandler.cs @@ -0,0 +1,15 @@ +using Shuttle.Access.Messages.v1; +using Shuttle.Hopper; +using Shuttle.Mediator; + +namespace Shuttle.Access.Server.v1.MessageHandlers; + +public class SetTenantLogoSvgHandler(IMediator mediator) : IMessageHandler +{ + public async Task HandleAsync(SetTenantLogoSvg message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + await mediator.SendAsync(new Application.SetTenantLogoSvg(message.Id, message.LogoSvg, message.AuditTenantId, message.AuditIdentityName), cancellationToken); + } +} diff --git a/Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoUrlHandler.cs b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoUrlHandler.cs new file mode 100644 index 00000000..026145f8 --- /dev/null +++ b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantLogoUrlHandler.cs @@ -0,0 +1,15 @@ +using Shuttle.Access.Messages.v1; +using Shuttle.Hopper; +using Shuttle.Mediator; + +namespace Shuttle.Access.Server.v1.MessageHandlers; + +public class SetTenantLogoUrlHandler(IMediator mediator) : IMessageHandler +{ + public async Task HandleAsync(SetTenantLogoUrl message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + await mediator.SendAsync(new Application.SetTenantLogoUrl(message.Id, message.LogoUrl, message.AuditTenantId, message.AuditIdentityName), cancellationToken); + } +} diff --git a/Shuttle.Access.Server/v1/MessageHandlers/SetTenantMaximumIdentitiesHandler.cs b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantMaximumIdentitiesHandler.cs new file mode 100644 index 00000000..a4b233ba --- /dev/null +++ b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantMaximumIdentitiesHandler.cs @@ -0,0 +1,15 @@ +using Shuttle.Access.Messages.v1; +using Shuttle.Hopper; +using Shuttle.Mediator; + +namespace Shuttle.Access.Server.v1.MessageHandlers; + +public class SetTenantMaximumIdentitiesHandler(IMediator mediator) : IMessageHandler +{ + public async Task HandleAsync(SetTenantMaximumIdentities message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + await mediator.SendAsync(new Application.SetTenantMaximumIdentities(message.Id, message.MaximumIdentities, message.AuditTenantId, message.AuditIdentityName), cancellationToken); + } +} diff --git a/Shuttle.Access.Server/v1/MessageHandlers/SetTenantNameHandler.cs b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantNameHandler.cs new file mode 100644 index 00000000..0d02ec27 --- /dev/null +++ b/Shuttle.Access.Server/v1/MessageHandlers/SetTenantNameHandler.cs @@ -0,0 +1,15 @@ +using Shuttle.Access.Messages.v1; +using Shuttle.Hopper; +using Shuttle.Mediator; + +namespace Shuttle.Access.Server.v1.MessageHandlers; + +public class SetTenantNameHandler(IMediator mediator) : IMessageHandler +{ + public async Task HandleAsync(SetTenantName message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + await mediator.SendAsync(new Application.SetTenantName(message.Id, message.Name, message.AuditTenantId, message.AuditIdentityName), cancellationToken); + } +} diff --git a/Shuttle.Access.WebApi.Contracts/v1/SetLogoSvg.cs b/Shuttle.Access.WebApi.Contracts/v1/SetLogoSvg.cs new file mode 100644 index 00000000..2184f352 --- /dev/null +++ b/Shuttle.Access.WebApi.Contracts/v1/SetLogoSvg.cs @@ -0,0 +1,6 @@ +namespace Shuttle.Access.WebApi.Contracts.v1; + +public class SetLogoSvg +{ + public string LogoSvg { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.WebApi.Contracts/v1/SetLogoUrl.cs b/Shuttle.Access.WebApi.Contracts/v1/SetLogoUrl.cs new file mode 100644 index 00000000..593b1355 --- /dev/null +++ b/Shuttle.Access.WebApi.Contracts/v1/SetLogoUrl.cs @@ -0,0 +1,6 @@ +namespace Shuttle.Access.WebApi.Contracts.v1; + +public class SetLogoUrl +{ + public string LogoUrl { get; set; } = string.Empty; +} diff --git a/Shuttle.Access.WebApi.Contracts/v1/SetMaximumIdentities.cs b/Shuttle.Access.WebApi.Contracts/v1/SetMaximumIdentities.cs new file mode 100644 index 00000000..81797561 --- /dev/null +++ b/Shuttle.Access.WebApi.Contracts/v1/SetMaximumIdentities.cs @@ -0,0 +1,6 @@ +namespace Shuttle.Access.WebApi.Contracts.v1; + +public class SetMaximumIdentities +{ + public int MaximumIdentities { get; set; } +} diff --git a/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs b/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs index 76d66db1..1114a295 100644 --- a/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs +++ b/Shuttle.Access.WebApi/Endpoints/TenantEndpoints.cs @@ -77,6 +77,30 @@ public static WebApplication MapTenantEndpoints(this WebApplication app, ApiVers .MapToApiVersion(apiVersion1) .RequirePermission(AccessPermissions.Tenants.Manage); + app.MapPatch("/v{version:apiVersion}/tenants/{id:Guid}/name", PatchName) + .WithTags("Tenants") + .WithApiVersionSet(versionSet) + .MapToApiVersion(apiVersion1) + .RequirePermission(AccessPermissions.Tenants.Manage); + + app.MapPatch("/v{version:apiVersion}/tenants/{id:Guid}/logo-url", PatchLogoUrl) + .WithTags("Tenants") + .WithApiVersionSet(versionSet) + .MapToApiVersion(apiVersion1) + .RequirePermission(AccessPermissions.Tenants.Manage); + + app.MapPatch("/v{version:apiVersion}/tenants/{id:Guid}/logo-svg", PatchLogoSvg) + .WithTags("Tenants") + .WithApiVersionSet(versionSet) + .MapToApiVersion(apiVersion1) + .RequirePermission(AccessPermissions.Tenants.Manage); + + app.MapPatch("/v{version:apiVersion}/tenants/{id:Guid}/maximum-identities", PatchMaximumIdentities) + .WithTags("Tenants") + .WithApiVersionSet(versionSet) + .MapToApiVersion(apiVersion1) + .RequirePermission(AccessPermissions.Tenants.Manage); + return app; } @@ -94,6 +118,62 @@ await messageDispatcher.DispatchAsync( return Results.Accepted(); } + private static async Task PatchName(Guid id, [FromBody] Contracts.v1.SetName message, ISessionContext sessionContext, MessageDispatcher messageDispatcher, CancellationToken cancellationToken) + { + await messageDispatcher.DispatchAsync( + () => sessionContext.Audit(new Messages.v1.SetTenantName + { + Id = id, + Name = message.Name + }), + () => new SetTenantName(id, message.Name, sessionContext.TenantId, sessionContext.Session.IdentityName), + cancellationToken); + + return Results.Accepted(); + } + + private static async Task PatchLogoUrl(Guid id, [FromBody] Contracts.v1.SetLogoUrl message, ISessionContext sessionContext, MessageDispatcher messageDispatcher, CancellationToken cancellationToken) + { + await messageDispatcher.DispatchAsync( + () => sessionContext.Audit(new Messages.v1.SetTenantLogoUrl + { + Id = id, + LogoUrl = message.LogoUrl + }), + () => new SetTenantLogoUrl(id, message.LogoUrl, sessionContext.TenantId, sessionContext.Session.IdentityName), + cancellationToken); + + return Results.Accepted(); + } + + private static async Task PatchLogoSvg(Guid id, [FromBody] Contracts.v1.SetLogoSvg message, ISessionContext sessionContext, MessageDispatcher messageDispatcher, CancellationToken cancellationToken) + { + await messageDispatcher.DispatchAsync( + () => sessionContext.Audit(new Messages.v1.SetTenantLogoSvg + { + Id = id, + LogoSvg = message.LogoSvg + }), + () => new SetTenantLogoSvg(id, message.LogoSvg, sessionContext.TenantId, sessionContext.Session.IdentityName), + cancellationToken); + + return Results.Accepted(); + } + + private static async Task PatchMaximumIdentities(Guid id, [FromBody] Contracts.v1.SetMaximumIdentities message, ISessionContext sessionContext, MessageDispatcher messageDispatcher, CancellationToken cancellationToken) + { + await messageDispatcher.DispatchAsync( + () => sessionContext.Audit(new Messages.v1.SetTenantMaximumIdentities + { + Id = id, + MaximumIdentities = message.MaximumIdentities + }), + () => new SetTenantMaximumIdentities(id, message.MaximumIdentities, sessionContext.TenantId, sessionContext.Session.IdentityName), + cancellationToken); + + return Results.Accepted(); + } + private static async Task Post([FromBody] Contracts.v1.RegisterTenant message, ISessionContext sessionContext, IIdentityQuery identityQuery, MessageDispatcher messageDispatcher, CancellationToken cancellationToken) { if (await identityQuery.CountAsync(new Query.Identity.Specification().WithName(message.AdministratorIdentityName), cancellationToken) == 0) diff --git a/Shuttle.Access/Events/Tenant.v1/LogoSvgSet.cs b/Shuttle.Access/Events/Tenant.v1/LogoSvgSet.cs new file mode 100644 index 00000000..40506fd7 --- /dev/null +++ b/Shuttle.Access/Events/Tenant.v1/LogoSvgSet.cs @@ -0,0 +1,6 @@ +namespace Shuttle.Access.Events.Tenant.v1; + +public class LogoSvgSet +{ + public string LogoSvg { get; set; } = string.Empty; +} diff --git a/Shuttle.Access/Events/Tenant.v1/LogoUrlSet.cs b/Shuttle.Access/Events/Tenant.v1/LogoUrlSet.cs new file mode 100644 index 00000000..73b4dbc8 --- /dev/null +++ b/Shuttle.Access/Events/Tenant.v1/LogoUrlSet.cs @@ -0,0 +1,6 @@ +namespace Shuttle.Access.Events.Tenant.v1; + +public class LogoUrlSet +{ + public string LogoUrl { get; set; } = string.Empty; +} diff --git a/Shuttle.Access/Events/Tenant.v1/MaximumIdentitiesSet.cs b/Shuttle.Access/Events/Tenant.v1/MaximumIdentitiesSet.cs new file mode 100644 index 00000000..64729bf6 --- /dev/null +++ b/Shuttle.Access/Events/Tenant.v1/MaximumIdentitiesSet.cs @@ -0,0 +1,6 @@ +namespace Shuttle.Access.Events.Tenant.v1; + +public class MaximumIdentitiesSet +{ + public int MaximumIdentities { get; set; } +} diff --git a/Shuttle.Access/Events/Tenant.v1/NameSet.cs b/Shuttle.Access/Events/Tenant.v1/NameSet.cs new file mode 100644 index 00000000..450e96ba --- /dev/null +++ b/Shuttle.Access/Events/Tenant.v1/NameSet.cs @@ -0,0 +1,6 @@ +namespace Shuttle.Access.Events.Tenant.v1; + +public class NameSet +{ + public string Name { get; set; } = string.Empty; +} diff --git a/Shuttle.Access/Tenant.cs b/Shuttle.Access/Tenant.cs index 1d377c30..60bf4c8a 100644 --- a/Shuttle.Access/Tenant.cs +++ b/Shuttle.Access/Tenant.cs @@ -43,6 +43,42 @@ private StatusSet On(StatusSet statusSet) return statusSet; } + private NameSet On(NameSet nameSet) + { + Guard.AgainstNull(nameSet); + + Name = nameSet.Name; + + return nameSet; + } + + private LogoUrlSet On(LogoUrlSet logoUrlSet) + { + Guard.AgainstNull(logoUrlSet); + + LogoUrl = logoUrlSet.LogoUrl; + + return logoUrlSet; + } + + private LogoSvgSet On(LogoSvgSet logoSvgSet) + { + Guard.AgainstNull(logoSvgSet); + + LogoSvg = logoSvgSet.LogoSvg; + + return logoSvgSet; + } + + private MaximumIdentitiesSet On(MaximumIdentitiesSet maximumIdentitiesSet) + { + Guard.AgainstNull(maximumIdentitiesSet); + + MaximumIdentities = maximumIdentitiesSet.MaximumIdentities; + + return maximumIdentitiesSet; + } + private Removed On(Removed removed) { Guard.AgainstNull(removed); @@ -79,4 +115,56 @@ public StatusSet SetStatus(TenantStatus status) Status = status }); } + + public NameSet SetName(string name) + { + if (Name == name) + { + throw new InvalidOperationException(string.Format(Resources.ValueAlreadySetException, nameof(name), name)); + } + + return On(new NameSet + { + Name = Guard.AgainstEmpty(name) + }); + } + + public LogoUrlSet SetLogoUrl(string logoUrl) + { + if (LogoUrl == logoUrl) + { + throw new InvalidOperationException(string.Format(Resources.ValueAlreadySetException, nameof(logoUrl), logoUrl)); + } + + return On(new LogoUrlSet + { + LogoUrl = logoUrl + }); + } + + public LogoSvgSet SetLogoSvg(string logoSvg) + { + if (LogoSvg == logoSvg) + { + throw new InvalidOperationException(string.Format(Resources.ValueAlreadySetException, nameof(logoSvg), logoSvg)); + } + + return On(new LogoSvgSet + { + LogoSvg = logoSvg + }); + } + + public MaximumIdentitiesSet SetMaximumIdentities(int maximumIdentities) + { + if (MaximumIdentities == maximumIdentities) + { + throw new InvalidOperationException(string.Format(Resources.ValueAlreadySetException, nameof(maximumIdentities), maximumIdentities)); + } + + return On(new MaximumIdentitiesSet + { + MaximumIdentities = maximumIdentities + }); + } } \ No newline at end of file