Files
tiku-backend.net/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs

304 lines
14 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Auth;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Content;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Tenancy;
internal sealed class TenantLifecycleService(
TikuDbContext dbContext,
IBackgroundJobService backgroundJobService,
IAuthSessionStore sessionStore,
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
ITenantPublicCacheInvalidator publicCacheInvalidator,
ITenantFeatureCacheInvalidator featureCacheInvalidator,
IObjectStorageService objectStorageService) : ITenantLifecycleService
{
private static readonly TimeSpan RecentExportWindow = TimeSpan.FromHours(24);
public async Task<TenantArchivePreview> PreviewArchiveAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken)
?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found.");
var blockers = new List<string>();
if (tenant.Status == TenantStatus.Archived) blockers.Add("tenant_already_archived");
if (await dbContext.BackgroundJobs.AnyAsync(item =>
item.TenantId == tenantId &&
item.Status == BackgroundJobStatus.Processing,
cancellationToken))
{
blockers.Add("processing_background_jobs");
}
var recentExport = await HasRecentExportAsync(tenantId, cancellationToken);
if (!recentExport) blockers.Add("recent_successful_export_required");
return new TenantArchivePreview(tenantId, blockers.Count == 0, recentExport, blockers);
}
public async Task<TenantLifecycleOperationItem> CreateExportAsync(
Guid tenantId,
Guid actorUserId,
CancellationToken cancellationToken = default)
{
await RequireTenantAsync(tenantId, cancellationToken);
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Export, null);
dbContext.TenantLifecycleOperations.Add(operation);
await dbContext.SaveChangesAsync(cancellationToken);
await backgroundJobService.EnqueueAsync(
new CreateBackgroundJobCommand(
tenantId,
"tenant_export",
JsonSerializer.SerializeToElement(new { operationId = operation.Id }),
MaxRetries: 3,
IdempotencyKey: $"tenant-export:{operation.Id:N}",
IsSystemJob: true),
cancellationToken);
return ToItem(operation);
}
public async Task<TenantLifecycleOperationItem?> GetOperationAsync(
Guid tenantId,
Guid operationId,
CancellationToken cancellationToken = default)
{
var operation = await dbContext.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == operationId,
cancellationToken);
return operation is null ? null : ToItem(operation);
}
public async Task<ObjectStorageSignedUrl> SignExportDownloadAsync(
Guid tenantId,
Guid operationId,
CancellationToken cancellationToken = default)
{
var operation = await dbContext.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == operationId &&
item.OperationType == TenantLifecycleOperationType.Export &&
item.Status == TenantLifecycleOperationStatus.Succeeded,
cancellationToken) ?? throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export is not ready.");
var asset = operation.ExportAssetId.HasValue
? await dbContext.ContentAssets.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == operation.ExportAssetId.Value,
cancellationToken)
: null;
if (asset is null || string.IsNullOrWhiteSpace(asset.ObjectKey))
{
throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export asset was not found.");
}
return await objectStorageService.SignDownloadAsync(
new ObjectStorageDownloadSignRequest(
tenantId,
ToProvider(asset.StorageProvider),
asset.Bucket,
asset.ObjectKey,
TimeSpan.FromMinutes(15),
asset.CdnUrl,
asset.FileName,
"attachment"),
cancellationToken);
}
public async Task<TenantLifecycleOperationItem> ArchiveAsync(
Guid tenantId,
Guid actorUserId,
string reason,
CancellationToken cancellationToken = default)
{
var preview = await PreviewArchiveAsync(tenantId, cancellationToken);
if (!preview.CanArchive)
{
throw new TenantLifecycleException("tenant_archive_blocked", string.Join(',', preview.Blockers));
}
var tenant = await RequireTenantAsync(tenantId, cancellationToken);
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Archive, reason);
operation.Status = TenantLifecycleOperationStatus.Succeeded;
operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow;
tenant.Status = TenantStatus.Archived;
await dbContext.TenantDomains.Where(item => item.TenantId == tenantId)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Disabled), cancellationToken);
dbContext.TenantLifecycleOperations.Add(operation);
AddAudit(tenantId, actorUserId, "tenant.archived", tenantId, reason);
await dbContext.SaveChangesAsync(cancellationToken);
await RevokeTenantSessionsAsync(tenantId, cancellationToken);
await InvalidateAsync(tenantId, cancellationToken);
return ToItem(operation);
}
public async Task<TenantLifecycleOperationItem> RestoreAsync(
Guid tenantId,
Guid actorUserId,
string reason,
CancellationToken cancellationToken = default)
{
var tenant = await RequireTenantAsync(tenantId, cancellationToken);
if (tenant.Status != TenantStatus.Archived)
{
throw new TenantLifecycleException("tenant_not_archived", "Only archived tenants can be restored.");
}
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Restore, reason);
operation.Status = TenantLifecycleOperationStatus.Succeeded;
operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow;
tenant.Status = TenantStatus.Suspended;
await dbContext.TenantDomains.Where(item => item.TenantId == tenantId)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Pending), cancellationToken);
dbContext.TenantLifecycleOperations.Add(operation);
AddAudit(tenantId, actorUserId, "tenant.restored_suspended", tenantId, reason);
await dbContext.SaveChangesAsync(cancellationToken);
await InvalidateAsync(tenantId, cancellationToken);
return ToItem(operation);
}
public async Task<TenantLifecycleOperationItem> TransferOwnerAsync(
Guid tenantId,
Guid actorUserId,
Guid targetUserId,
string reason,
CancellationToken cancellationToken = default)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var tenant = await RequireTenantAsync(tenantId, cancellationToken);
var target = await dbContext.TenantMemberships.SingleOrDefaultAsync(item =>
item.TenantId == tenantId && item.UserId == targetUserId && item.Status == MembershipStatus.Active,
cancellationToken) ?? throw new TenantLifecycleException(
"tenant_owner_target_not_active_member",
"New owner must be an existing active tenant member.");
var previousOwnerId = tenant.OwnerUserId;
if (previousOwnerId == targetUserId)
{
throw new TenantLifecycleException("tenant_owner_unchanged", "Target user is already the tenant owner.");
}
var previous = previousOwnerId.HasValue
? await dbContext.TenantMemberships.SingleOrDefaultAsync(item =>
item.TenantId == tenantId && item.UserId == previousOwnerId.Value,
cancellationToken)
: null;
if (previous is not null) previous.Role = TenantRole.TenantAdmin;
target.Role = TenantRole.TenantOwner;
tenant.OwnerUserId = targetUserId;
var ownerRoleId = await dbContext.TenantBackendRoles
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner")
.Select(item => (Guid?)item.Id)
.SingleOrDefaultAsync(cancellationToken);
if (ownerRoleId.HasValue)
{
if (previousOwnerId.HasValue)
{
await dbContext.TenantBackendUserRoles
.Where(item => item.TenantId == tenantId && item.UserId == previousOwnerId.Value && item.RoleId == ownerRoleId.Value)
.ExecuteDeleteAsync(cancellationToken);
}
if (!await dbContext.TenantBackendUserRoles.AnyAsync(item =>
item.TenantId == tenantId && item.UserId == targetUserId && item.RoleId == ownerRoleId.Value,
cancellationToken))
{
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = tenantId,
UserId = targetUserId,
RoleId = ownerRoleId.Value
});
}
}
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.OwnerTransfer, reason);
operation.TargetUserId = targetUserId;
operation.Status = TenantLifecycleOperationStatus.Succeeded;
operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow;
operation.Result = JsonSerializer.SerializeToElement(new { previousOwnerId, newOwnerId = targetUserId });
dbContext.TenantLifecycleOperations.Add(operation);
AddAudit(tenantId, actorUserId, "tenant.owner_transferred", targetUserId, reason);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await InvalidateAsync(tenantId, cancellationToken);
return ToItem(operation);
}
private Task<bool> HasRecentExportAsync(Guid tenantId, CancellationToken cancellationToken)
{
var cutoff = DateTimeOffset.UtcNow - RecentExportWindow;
return dbContext.TenantLifecycleOperations.AnyAsync(item =>
item.TenantId == tenantId &&
item.OperationType == TenantLifecycleOperationType.Export &&
item.Status == TenantLifecycleOperationStatus.Succeeded &&
item.CompletedAt >= cutoff,
cancellationToken);
}
private async Task<Tenant> RequireTenantAsync(Guid tenantId, CancellationToken cancellationToken) =>
await dbContext.Tenants.SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ??
throw new TenantLifecycleException("tenant_not_found", "Tenant was not found.");
private async Task RevokeTenantSessionsAsync(Guid tenantId, CancellationToken cancellationToken)
{
var userIds = await dbContext.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.Select(item => item.UserId)
.Distinct()
.ToArrayAsync(cancellationToken);
foreach (var userId in userIds)
{
await sessionStore.RevokeRealmAsync(userId, AuthRealm.Tenant, tenantId, "tenant_archived", cancellationToken);
}
}
private async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken)
{
await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
await featureCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
}
private static TenantLifecycleOperation CreateOperation(
Guid tenantId,
Guid actorUserId,
TenantLifecycleOperationType type,
string? reason) => new()
{
TenantId = tenantId,
RequestedBy = actorUserId,
OperationType = type,
Reason = reason?.Trim()
};
private void AddAudit(Guid tenantId, Guid actorUserId, string action, Guid targetId, string reason) =>
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = tenantId,
ActorUserId = actorUserId,
Action = action,
TargetType = "tenant",
TargetId = targetId.ToString(),
Details = JsonSerializer.SerializeToElement(new { reason })
});
private static string ToProvider(AssetStorageProvider provider) => provider switch
{
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
_ => ObjectStorageProviders.ExternalUrl
};
private static TenantLifecycleOperationItem ToItem(TenantLifecycleOperation operation) => new(
operation.Id,
operation.TenantId,
operation.OperationType,
operation.Status,
operation.RequestedBy,
operation.TargetUserId,
operation.ExportAssetId,
operation.Reason,
operation.LastError,
operation.CreatedAt,
operation.CompletedAt);
}