feat: bootstrap local platform development
This commit is contained in:
192
Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs
Normal file
192
Tiku.Infrastructure/Bootstrap/DevelopmentPlatformAdminSeeder.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Bootstrap;
|
||||
|
||||
public static class DevelopmentPlatformAdminSeeder
|
||||
{
|
||||
public const string Email = "admin@tiku.local";
|
||||
public const string RoleCode = PlatformAdminBootstrapper.SuperAdminRoleCode;
|
||||
|
||||
public static void Configure(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder
|
||||
.UseSeeding((context, _) => Seed((TikuDbContext)context))
|
||||
.UseAsyncSeeding((context, _, cancellationToken) =>
|
||||
SeedAsync((TikuDbContext)context, cancellationToken));
|
||||
}
|
||||
|
||||
public static bool Seed(TikuDbContext dbContext)
|
||||
{
|
||||
ReloadPostgresTypes(dbContext);
|
||||
|
||||
if (dbContext.PlatformBackendUserRoles.Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var temporaryPassword = GenerateTemporaryPassword();
|
||||
EnsureEmailIsAvailable(dbContext.Users.Any(user =>
|
||||
user.NormalizedEmail == Email.ToUpperInvariant() ||
|
||||
user.NormalizedUserName == Email.ToUpperInvariant()));
|
||||
var permissionCodes = BackendPermissions.Platform.ToArray();
|
||||
var existingPermissionCodes = dbContext.BackendPermissions
|
||||
.Where(permission => permissionCodes.Contains(permission.Code))
|
||||
.Select(permission => permission.Code)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
|
||||
dbContext.SaveChanges();
|
||||
WriteFirstLoginInstructions(temporaryPassword);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static async Task<bool> SeedAsync(
|
||||
TikuDbContext dbContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ReloadPostgresTypesAsync(dbContext, cancellationToken);
|
||||
|
||||
if (await dbContext.PlatformBackendUserRoles.AnyAsync(cancellationToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var temporaryPassword = GenerateTemporaryPassword();
|
||||
var normalizedEmail = Email.ToUpperInvariant();
|
||||
EnsureEmailIsAvailable(await dbContext.Users.AnyAsync(user =>
|
||||
user.NormalizedEmail == normalizedEmail ||
|
||||
user.NormalizedUserName == normalizedEmail, cancellationToken));
|
||||
var permissionCodes = BackendPermissions.Platform.ToArray();
|
||||
var existingPermissionCodes = (await dbContext.BackendPermissions
|
||||
.Where(permission => permissionCodes.Contains(permission.Code))
|
||||
.Select(permission => permission.Code)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
AddSeedGraph(dbContext, temporaryPassword, permissionCodes, existingPermissionCodes);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
WriteFirstLoginInstructions(temporaryPassword);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddSeedGraph(
|
||||
TikuDbContext dbContext,
|
||||
string temporaryPassword,
|
||||
IReadOnlyCollection<string> permissionCodes,
|
||||
IReadOnlySet<string> existingPermissionCodes)
|
||||
{
|
||||
var normalizedEmail = Email.ToUpperInvariant();
|
||||
var user = new User
|
||||
{
|
||||
Email = Email,
|
||||
NormalizedEmail = normalizedEmail,
|
||||
UserName = Email,
|
||||
NormalizedUserName = normalizedEmail,
|
||||
Name = "Local Platform Administrator",
|
||||
EmailConfirmed = true,
|
||||
Status = UserStatus.Active,
|
||||
ForcePasswordChange = true,
|
||||
TwoFactorEnabled = false
|
||||
};
|
||||
var passwordHasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
|
||||
{
|
||||
IterationCount = 210_000
|
||||
}));
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, temporaryPassword);
|
||||
|
||||
var role = new PlatformBackendRole
|
||||
{
|
||||
Code = RoleCode,
|
||||
Name = "Platform Super Administrator",
|
||||
Description = "Built-in Development administrator created by EF Core data seeding.",
|
||||
Status = BackendRoleStatus.Active,
|
||||
IsSystem = true
|
||||
};
|
||||
dbContext.Users.Add(user);
|
||||
dbContext.PlatformBackendRoles.Add(role);
|
||||
dbContext.BackendPermissions.AddRange(
|
||||
permissionCodes
|
||||
.Where(code => !existingPermissionCodes.Contains(code))
|
||||
.Select(code => new BackendPermission
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
Module = "platform",
|
||||
Description = "Built-in platform permission.",
|
||||
IsSystem = true
|
||||
}));
|
||||
dbContext.PlatformBackendRolePermissions.AddRange(
|
||||
permissionCodes.Select(code => new PlatformBackendRolePermission
|
||||
{
|
||||
RoleId = role.Id,
|
||||
PermissionCode = code
|
||||
}));
|
||||
dbContext.PlatformBackendUserRoles.Add(new PlatformBackendUserRole
|
||||
{
|
||||
UserId = user.Id,
|
||||
RoleId = role.Id
|
||||
});
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
ActorUserId = user.Id,
|
||||
Action = "platform.bootstrap_admin.created",
|
||||
TargetType = "users",
|
||||
TargetId = user.Id.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
user.Email,
|
||||
RoleCode,
|
||||
ForcePasswordChange = true,
|
||||
MfaEnrollmentRequired = true,
|
||||
Source = "ef_core_use_seeding"
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private static string GenerateTemporaryPassword() =>
|
||||
$"Tiku!{Convert.ToHexString(RandomNumberGenerator.GetBytes(16))}9a";
|
||||
|
||||
private static void ReloadPostgresTypes(TikuDbContext dbContext)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
((NpgsqlConnection)dbContext.Database.GetDbConnection()).ReloadTypes();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReloadPostgresTypesAsync(
|
||||
TikuDbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
await ((NpgsqlConnection)dbContext.Database.GetDbConnection())
|
||||
.ReloadTypesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureEmailIsAvailable(bool isAssigned)
|
||||
{
|
||||
if (isAssigned)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot create the Development platform administrator because '{Email}' is already assigned.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFirstLoginInstructions(string temporaryPassword)
|
||||
{
|
||||
Console.WriteLine("Development platform administrator created by EF Core data seeding.");
|
||||
Console.WriteLine($" Account: {Email}");
|
||||
Console.WriteLine($" Temporary password: {temporaryPassword}");
|
||||
Console.WriteLine(" Change the password and enroll TOTP MFA at first sign-in. This password is shown only once.");
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
string connectionString,
|
||||
Action<DbContextOptionsBuilder>? configureDatabase = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
|
||||
@@ -62,6 +63,7 @@ public static class DependencyInjection
|
||||
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
||||
options.UseNpgsql(dataSource, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||
configureDatabase?.Invoke(options);
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||
});
|
||||
services.AddIdentityCore<User>(options =>
|
||||
|
||||
Reference in New Issue
Block a user