Skip to content

Commit a32c7b2

Browse files
committed
Automate tenant provisioning and lifecycle management
Introduced a robust workflow for automating tenant provisioning, activation, and health verification. Added `TenantProvisioning` and `TenantProvisioningStep` entities to track provisioning status and steps. Integrated Hangfire for background job processing with inline fallback for development. Enhanced multitenancy support with `Finbuckle.MultiTenant`, updated `AppTenantInfo` for immutability, and added validation for subscription backdating. Added endpoints for retrieving and retrying provisioning status. Improved health checks, logging, and auditing with per-tenant context. Refactored `TenantService` to delegate provisioning logic to `TenantProvisioningService`. Updated database migrations and configurations for streamlined provisioning. Upgraded dependencies, cleaned up outdated migrations, and added development-friendly features like auto-provisioning on startup.
2 parents 0549750 + ccbfb6b commit a32c7b2

58 files changed

Lines changed: 1427 additions & 363 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/stories/tenant-lifecycle-automation.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,10 @@ Goal: automate tenant provisioning, activation, and health verification so new t
7979
- Double-submit provisioning for same tenant does not run concurrent workflows (dedupe/lock).
8080
- Partial seeds are safe to re-run (no duplicate roles/users; admin user upsert).
8181
- Health check reports degraded for tenants with failed provisioning; improves after successful retry.
82+
83+
## Progress Update (Current State)
84+
- Provisioning workflow implemented with persisted status/steps and 202 responses on tenant creation; retry endpoint available.
85+
- Background provisioning via Hangfire, with inline fallback when Hangfire/storage is unavailable (dev-friendly).
86+
- Startup hosted services: tenant catalog migrate/seed (root tenant) and optional auto-provision enqueue.
87+
- Provider-aware TenantDbContextFactory to select PostgreSQL via appsettings.
88+
- Audit pipeline fixed to stamp tenant/user on events; audit sink writes per-tenant batches.

src/BuildingBlocks/Jobs/Extensions.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ public static class Extensions
1313
{
1414
public static IServiceCollection AddHeroJobs(this IServiceCollection services)
1515
{
16+
services.AddOptions<HangfireOptions>()
17+
.BindConfiguration(nameof(HangfireOptions));
18+
1619
services.AddHangfireServer(options =>
1720
{
1821
options.HeartbeatInterval = TimeSpan.FromSeconds(30);
@@ -23,10 +26,9 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services)
2326

2427
services.AddHangfire((provider, config) =>
2528
{
26-
var dbOptions = provider
27-
.GetRequiredService<IConfiguration>()
28-
.GetSection(nameof(DatabaseOptions))
29-
.Get<DatabaseOptions>() ?? throw new CustomException("Database options not found");
29+
var configuration = provider.GetRequiredService<IConfiguration>();
30+
var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get<DatabaseOptions>()
31+
?? throw new CustomException("Database options not found");
3032

3133
switch (dbOptions.Provider.ToUpperInvariant())
3234
{

src/BuildingBlocks/Jobs/FshJobActivator.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,7 @@ private void ReceiveParameters()
3838
if (tenantInfo is not null)
3939
{
4040
_scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
41-
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>
42-
{
43-
TenantInfo = tenantInfo
44-
};
41+
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenantInfo);
4542
}
4643

4744
string userId = _context.GetJobParameter<string>(QueryStringKeys.UserId);
@@ -60,4 +57,4 @@ public override object Resolve(Type type) =>
6057
? _context
6158
: _scope.ServiceProvider.GetService(serviceType);
6259
}
63-
}
60+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
namespace FSH.Framework.Jobs;
1+
namespace FSH.Framework.Jobs;
22

33
public class HangfireOptions
44
{
55
public string UserName { get; set; } = "admin";
66
public string Password { get; set; } = "Secure1234!Me";
77
public string Route { get; set; } = "/jobs";
8-
}
8+
}

src/BuildingBlocks/Jobs/LogJobFilter.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public void OnCreating(CreatingContext context)
1919
var job = context.Job;
2020
var jobName = GetJobName(job);
2121

22-
Logger.InfoFormat(
22+
Logger.DebugFormat(
2323
"Creating job for {0}.", jobName);
2424
}
2525

@@ -30,7 +30,7 @@ public void OnCreated(CreatedContext context)
3030
var jobId = context.BackgroundJob?.Id ?? "<unknown>";
3131
var recurringJobId = context.Parameters.TryGetValue("RecurringJobId", out var r) ? r : null;
3232

33-
Logger.InfoFormat(
33+
Logger.DebugFormat(
3434
"Job created: Id={0}, Name={1}, RecurringJobId={2}",
3535
jobId,
3636
jobName,
@@ -45,7 +45,7 @@ public void OnPerforming(PerformingContext context)
4545
var recurringJobId = context.GetJobParameter<string>("RecurringJobId") ?? "<none>";
4646
var args = FormatArguments(job.Args);
4747

48-
Logger.InfoFormat(
48+
Logger.DebugFormat(
4949
"Starting job: Id={0}, Name={1}, RecurringJobId={2}, Queue={3}, Args={4}",
5050
backgroundJob.Id,
5151
jobName,
@@ -60,7 +60,7 @@ public void OnPerformed(PerformedContext context)
6060
var job = backgroundJob.Job;
6161
var jobName = GetJobName(job);
6262

63-
Logger.InfoFormat(
63+
Logger.DebugFormat(
6464
"Job completed: Id={0}, Name={1}, Succeeded={2}",
6565
backgroundJob.Id,
6666
jobName,
@@ -81,7 +81,7 @@ public void OnStateElection(ElectStateContext context)
8181

8282
public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
8383
{
84-
Logger.InfoFormat(
84+
Logger.DebugFormat(
8585
"Job state changed: Id={0}, Name={1}, OldState={2}, NewState={3}",
8686
context.BackgroundJob.Id,
8787
GetJobName(context.BackgroundJob.Job),
@@ -91,7 +91,7 @@ public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction tran
9191

9292
public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
9393
{
94-
Logger.InfoFormat(
94+
Logger.DebugFormat(
9595
"Job state unapplied: Id={0}, Name={1}, OldState={2}",
9696
context.BackgroundJob.Id,
9797
GetJobName(context.BackgroundJob.Job),
Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
using Finbuckle.MultiTenant.Abstractions;
1+
using Finbuckle.MultiTenant.Abstractions;
22

33
namespace FSH.Framework.Shared.Multitenancy;
44

5-
public class AppTenantInfo : ITenantInfo, IAppTenantInfo
5+
public record AppTenantInfo(string Id, string Identifier, string? Name = null)
6+
: TenantInfo(Id, Identifier, Name), IAppTenantInfo
67
{
7-
public AppTenantInfo()
8+
// Parameterless constructor for tooling/EF.
9+
public AppTenantInfo() : this(string.Empty, string.Empty)
810
{
911
}
1012

1113
public AppTenantInfo(string id, string name, string? connectionString, string adminEmail, string? issuer = null)
14+
: this(id, id, name)
1215
{
13-
Id = id;
14-
Identifier = id;
15-
Name = name;
1616
ConnectionString = connectionString ?? string.Empty;
1717
AdminEmail = adminEmail;
1818
IsActive = true;
@@ -21,12 +21,8 @@ public AppTenantInfo(string id, string name, string? connectionString, string ad
2121
// Add Default 1 Month Validity for all new tenants. Something like a DEMO period for tenants.
2222
ValidUpto = DateTime.UtcNow.AddMonths(1);
2323
}
24-
public string Id { get; set; } = default!;
25-
public string Identifier { get; set; } = default!;
26-
27-
public string Name { get; set; } = default!;
28-
public string ConnectionString { get; set; } = default!;
2924

25+
public string ConnectionString { get; set; } = string.Empty;
3026
public string AdminEmail { get; set; } = default!;
3127
public bool IsActive { get; set; }
3228
public DateTime ValidUpto { get; set; }
@@ -35,10 +31,13 @@ public AppTenantInfo(string id, string name, string? connectionString, string ad
3531
public void AddValidity(int months) =>
3632
ValidUpto = ValidUpto.AddMonths(months);
3733

38-
public void SetValidity(in DateTime validTill) =>
39-
ValidUpto = ValidUpto < validTill
40-
? validTill
34+
public void SetValidity(in DateTime validTill)
35+
{
36+
var normalized = validTill;
37+
ValidUpto = ValidUpto < normalized
38+
? normalized
4139
: throw new InvalidOperationException("Subscription cannot be backdated.");
40+
}
4241

4342
public void Activate()
4443
{
@@ -59,8 +58,10 @@ public void Deactivate()
5958

6059
IsActive = false;
6160
}
62-
string? ITenantInfo.Id { get => Id; set => Id = value ?? throw new InvalidOperationException("Id can't be null."); }
63-
string? ITenantInfo.Identifier { get => Identifier; set => Identifier = value ?? throw new InvalidOperationException("Identifier can't be null."); }
64-
string? ITenantInfo.Name { get => Name; set => Name = value ?? throw new InvalidOperationException("Name can't be null."); }
65-
string? IAppTenantInfo.ConnectionString { get => ConnectionString; set => ConnectionString = value ?? throw new InvalidOperationException("ConnectionString can't be null."); }
66-
}
61+
62+
string? IAppTenantInfo.ConnectionString
63+
{
64+
get => ConnectionString;
65+
set => ConnectionString = value ?? throw new InvalidOperationException("ConnectionString can't be null.");
66+
}
67+
}

src/BuildingBlocks/Shared/Multitenancy/MultitenancyConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public static class Root
1313

1414
public const string DefaultPassword = "123Pa$$word!";
1515
public const string Identifier = "tenant";
16+
public const string Schema = "tenant";
1617

1718
public static class Permissions
1819
{

src/BuildingBlocks/Shared/Shared.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
<AssemblyName>FSH.Framework.Shared</AssemblyName>
66
</PropertyGroup>
77
<ItemGroup>
8-
<FrameworkReference Include="Microsoft.AspNetCore.App" />
8+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
99
</ItemGroup>
1010
<ItemGroup>
11+
<PackageReference Include="Finbuckle.MultiTenant.Abstractions" />
1112
<PackageReference Include="Finbuckle.MultiTenant" />
1213
</ItemGroup>
1314

src/Directory.Packages.props

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@
99
<IsPackable>true</IsPackable>
1010
</PropertyGroup>
1111
<ItemGroup Label="Aspire">
12-
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.0.0" />
13-
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.0.0" />
14-
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.0" />
12+
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.0.1" />
13+
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.0.1" />
14+
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
1515
<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0" />
1616
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
1717
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
18-
<PackageVersion Include="MudBlazor" Version="8.14.0" />
18+
<PackageVersion Include="MudBlazor" Version="8.15.0" />
1919
<PackageVersion Include="MudBlazor.ThemeManager" Version="3.0.0" />
20-
<PackageVersion Include="Npgsql.OpenTelemetry" Version="10.0.0-rc.1" />
20+
<PackageVersion Include="Npgsql.OpenTelemetry" Version="10.0.0" />
2121
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
2222
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
2323
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
@@ -36,10 +36,12 @@
3636
<PackageVersion Include="Asp.Versioning.Http" Version="8.1.0" />
3737
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
3838
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
39-
<PackageVersion Include="Finbuckle.MultiTenant" Version="9.4.2" />
40-
<PackageVersion Include="Finbuckle.MultiTenant.AspNetCore" Version="9.4.2" />
41-
<PackageVersion Include="Finbuckle.MultiTenant.EntityFrameworkCore" Version="9.4.2" />
42-
<PackageVersion Include="FluentValidation" Version="12.1.0" />
39+
<PackageVersion Include="Finbuckle.MultiTenant" Version="10.0.0" />
40+
<PackageVersion Include="Finbuckle.MultiTenant.AspNetCore" Version="10.0.0" />
41+
<PackageVersion Include="Finbuckle.MultiTenant.EntityFrameworkCore" Version="10.0.0" />
42+
<PackageVersion Include="Finbuckle.MultiTenant.Abstractions" Version="10.0.0" />
43+
<PackageVersion Include="Finbuckle.MultiTenant.Identity.EntityFrameworkCore" Version="10.0.0" />
44+
<PackageVersion Include="FluentValidation" Version="12.1.1" />
4345
<PackageVersion Include="Hangfire" Version="1.8.22" />
4446
<PackageVersion Include="Hangfire.Core" Version="1.8.22" />
4547
<PackageVersion Include="Hangfire.InMemory" Version="1.0.0" />
@@ -74,12 +76,12 @@
7476
<PackageVersion Include="MimeKit" Version="4.14.0" />
7577
<PackageVersion Include="NetArchTest.Rules" Version="1.3.2" />
7678
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
77-
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-rc.2" />
78-
<PackageVersion Include="Scalar.AspNetCore" Version="2.10.3" />
79+
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
80+
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.0" />
7981
<PackageVersion Include="Serilog" Version="4.3.1-dev-02395" />
80-
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
82+
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
8183
<PackageVersion Include="Serilog.Enrichers.CorrelationId" Version="3.0.1" />
82-
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.15.0.120848" />
84+
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.16.1.129956" />
8385
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
8486
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
8587
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />

src/Modules/Auditing/Modules.Auditing/AuditingModule.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public void ConfigureServices(IHostApplicationBuilder builder)
4040
// Enrichers used by Audit.Configure (scoped, run on request thread)
4141
builder.Services.AddScoped<IAuditMaskingService, JsonMaskingService>();
4242
builder.Services.AddHostedService<AuditingConfigurator>();
43-
builder.Services.AddSingleton<IAuditScope, HttpAuditScope>();
43+
builder.Services.AddScoped<IAuditScope, HttpAuditScope>();
4444

4545
builder.Services.AddSingleton<ChannelAuditPublisher>();
4646
builder.Services.AddSingleton<IAuditPublisher>(sp => sp.GetRequiredService<ChannelAuditPublisher>());

0 commit comments

Comments
 (0)