Skip to content

Commit ccbfb6b

Browse files
authored
Feature/tenant lifecycle automation (#1150)
* Add Tenant Lifecycle Automation design document Expanded `tenant-lifecycle-automation.md` with a detailed framework for automating tenant provisioning, activation, and health verification. - Defined goals, scope, and non-goals for the automation process. - Outlined personas (Platform Admin, Tenant Admin, SRE/DevOps). - Documented high-level workflow for provisioning steps. - Specified functional, operational, and security requirements. - Added acceptance criteria for successful provisioning. - Included failure/recovery criteria for error handling and retries. This update ensures a robust, secure, and observable tenant lifecycle management process. * Enhance multitenancy and auditing modules - Introduced a tenant provisioning workflow with support for migrations, seeding, and cache warming. - Added `TenantProvisioningService` and related entities to manage provisioning steps and statuses. - Updated `ITenantService` with methods for tenant migration and seeding. - Added endpoints for retrieving and retrying tenant provisioning statuses. - Integrated Hangfire for background job execution and improved its configuration. - Refactored logging in `LogJobFilter` to use `Logger.DebugFormat`. - Updated `TenantDbContext` and added migrations for multitenancy and auditing. - Adjusted service lifetimes for audit and tenant provisioning components. - Updated `appsettings.json` for database connection and Serilog configuration. - Removed redundant code and improved maintainability with modern C# features. * Automate tenant provisioning and improve workflows Implemented automated tenant provisioning with persisted status and retry support. Added background provisioning via Hangfire with inline fallback for dev environments. Introduced startup hosted services for tenant catalog migration/seeding and optional auto-provision enqueue. Enhanced `TenantDbContextFactory` to support PostgreSQL via appsettings. Fixed audit pipeline to include tenant/user stamps and write per-tenant log batches. * update packages to 10
1 parent affa69f commit ccbfb6b

58 files changed

Lines changed: 1508 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.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Tenant Lifecycle Automation
2+
3+
Goal: automate tenant provisioning, activation, and health verification so new tenants are production-ready with minimal manual steps while preserving multi-tenant safety, auditing, and observability.
4+
5+
## Scope (In)
6+
- Create/activate tenant triggers background provisioning workflow.
7+
- Per-tenant database creation (or schema), migrations, and seed data.
8+
- Default identity bootstrap (admin user/roles/permissions) tied to tenant.
9+
- Health verification and status reporting.
10+
- Idempotent, retryable orchestration with audit and telemetry.
11+
- Admin endpoints/UX to view workflow state and retry/re-run steps.
12+
13+
## Non-Goals (Out)
14+
- Full-feature feature-flag platform.
15+
- Billing/usage metering.
16+
- Cross-cloud infrastructure automation (K8s, DNS, CDN).
17+
18+
## Personas
19+
- Platform Admin: initiates tenant creation, monitors status, retries failed steps.
20+
- Tenant Admin: receives bootstrap credentials, validates app access post-provision.
21+
- SRE/DevOps: monitors health, investigates failed jobs, tunes resilience.
22+
23+
## High-Level Flow
24+
1) Admin issues `CreateTenant` (or activates an existing tenant).
25+
2) System enqueues a provisioning job (Hangfire) keyed by TenantId + correlation.
26+
3) Workflow steps (all idempotent):
27+
- Validate tenant metadata (provider, connection string template, validity).
28+
- Create tenant database/schema (or ensure exists) using provider-specific strategy.
29+
- Apply EF Core migrations for each enabled module (Multitenancy, Identity, Auditing, etc.).
30+
- Seed baseline data (roles, permissions, admin user with reset token, root tenant data if applicable).
31+
- Warm caches if enabled (e.g., permissions).
32+
- Emit audit + telemetry events for each step.
33+
4) Mark tenant as `Active` when all steps succeed; surface status via API.
34+
5) On failure: capture error, mark status `Failed`, allow retry/resume from failed step.
35+
36+
## Functional Requirements
37+
- Provisioning job:
38+
- Runs as Hangfire background job; supports manual trigger and automatic trigger on create/activate.
39+
- Stores per-step status, timestamps, and error messages (persisted per tenant).
40+
- Uses correlation/trace IDs; logs to OpenTelemetry.
41+
- Supports cancellation and exponential backoff retries.
42+
- Database orchestration:
43+
- Provider-aware strategies (PostgreSQL initial target; hooks for SQL Server).
44+
- Option to create database if missing; else validate connectivity.
45+
- Runs module migrations in deterministic order; stops on first failure.
46+
- Seeding:
47+
- Seeds Identity admin user, default roles/permissions, and tenant metadata.
48+
- Issues one-time admin credential or password reset token for Tenant Admin.
49+
- Seeds demo data optionally (flag).
50+
- Status surface:
51+
- API to fetch provisioning status history per tenant.
52+
- Health check should include tenant provisioning status (ready/degraded/failed).
53+
- Safety & idempotency:
54+
- All steps re-runnable without corrupting state (check-before-create).
55+
- Guard against concurrent provisioning for same tenant.
56+
- Respect tenant validity/activation flags.
57+
58+
## Operational/Observability Requirements
59+
- Emit structured logs with TenantId, correlationId, step name, duration, outcome.
60+
- Create OpenTelemetry spans for each step (db create, migrate, seed, cache warm).
61+
- Publish audit events for lifecycle changes (Requested, Started, StepFailed, Completed).
62+
- Expose metrics: provision_duration_seconds, provision_step_failures_total, active_tenants.
63+
64+
## Security Requirements
65+
- No secrets in logs/audits; hash/scrub credentials.
66+
- Bootstrap credentials delivered via secure channel (email with reset token or out-of-band).
67+
- Enforce tenant isolation during provisioning (context scopes, connection string guards).
68+
- Authorization: only platform admins can trigger or retry provisioning.
69+
70+
## Acceptance Criteria (Happy Path)
71+
- Creating a tenant triggers a job that:
72+
- Creates/validates DB, applies migrations for all enabled modules, seeds identity/admin, warms caches.
73+
- Marks tenant Active and Ready; status endpoint shows completed steps with durations.
74+
- Audit trail shows Requested -> Started -> Completed with TenantId and correlationId.
75+
- Metrics and traces include the provisioning spans and surface in health checks.
76+
77+
## Failure/Recovery Criteria
78+
- If migrations fail, status is Failed with error details; job can be retried and resumes idempotently.
79+
- Double-submit provisioning for same tenant does not run concurrent workflows (dedupe/lock).
80+
- Partial seeds are safe to re-run (no duplicate roles/users; admin user upsert).
81+
- 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)