feat: bootstrap local platform development

This commit is contained in:
2026-07-28 17:01:44 +08:00
parent 5732df8886
commit e7d350ec3d
19 changed files with 3230 additions and 7 deletions

View 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.");
}
}