317 lines
15 KiB
C#
317 lines
15 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(
|
|
ITenancyPersistence tenancyPersistence,
|
|
IIdentityPersistence identityPersistence,
|
|
IContentAssetPersistence contentAssetPersistence,
|
|
IJobsOperationsPersistence jobsOperationsPersistence,
|
|
IBackgroundJobQueue backgroundJobService,
|
|
IAuthSessionStore sessionStore,
|
|
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
|
|
ITenantPublicCacheInvalidator publicCacheInvalidator,
|
|
ITenantFeatureCacheInvalidator featureCacheInvalidator,
|
|
IAuthorizationStateInvalidator authorizationStateInvalidator,
|
|
IObjectStorageService objectStorageService) : ITenantLifecycleService
|
|
{
|
|
private static readonly TimeSpan RecentExportWindow = TimeSpan.FromHours(24);
|
|
|
|
public async Task<TenantArchivePreview> PreviewArchiveAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tenant = await tenancyPersistence.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 jobsOperationsPersistence.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);
|
|
jobsOperationsPersistence.TenantLifecycleOperations.Add(operation);
|
|
await tenancyPersistence.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 jobsOperationsPersistence.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 jobsOperationsPersistence.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 contentAssetPersistence.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),
|
|
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 tenancyPersistence.TenantDomains.Where(item => item.TenantId == tenantId)
|
|
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Disabled),
|
|
cancellationToken);
|
|
jobsOperationsPersistence.TenantLifecycleOperations.Add(operation);
|
|
AddAudit(tenantId, actorUserId, "tenant.archived", tenantId, reason);
|
|
await tenancyPersistence.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 tenancyPersistence.TenantDomains.Where(item => item.TenantId == tenantId)
|
|
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Pending),
|
|
cancellationToken);
|
|
jobsOperationsPersistence.TenantLifecycleOperations.Add(operation);
|
|
AddAudit(tenantId, actorUserId, "tenant.restored_suspended", tenantId, reason);
|
|
await tenancyPersistence.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 tenancyPersistence.Database.BeginTransactionAsync(cancellationToken);
|
|
var tenant = await RequireTenantAsync(tenantId, cancellationToken);
|
|
var target = await identityPersistence.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 identityPersistence.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 jobsOperationsPersistence.TenantBackendRoles
|
|
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner")
|
|
.Select(item => (Guid?)item.Id)
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (ownerRoleId.HasValue)
|
|
{
|
|
if (previousOwnerId.HasValue)
|
|
await jobsOperationsPersistence.TenantBackendUserRoles
|
|
.Where(item =>
|
|
item.TenantId == tenantId && item.UserId == previousOwnerId.Value &&
|
|
item.RoleId == ownerRoleId.Value)
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
if (!await jobsOperationsPersistence.TenantBackendUserRoles.AnyAsync(item =>
|
|
item.TenantId == tenantId && item.UserId == targetUserId && item.RoleId == ownerRoleId.Value,
|
|
cancellationToken))
|
|
jobsOperationsPersistence.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 });
|
|
jobsOperationsPersistence.TenantLifecycleOperations.Add(operation);
|
|
AddAudit(tenantId, actorUserId, "tenant.owner_transferred", targetUserId, reason);
|
|
await tenancyPersistence.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
|
if (previousOwnerId.HasValue)
|
|
await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, previousOwnerId.Value,
|
|
cancellationToken);
|
|
await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, targetUserId, cancellationToken);
|
|
await InvalidateAsync(tenantId, cancellationToken);
|
|
return ToItem(operation);
|
|
}
|
|
|
|
private Task<bool> HasRecentExportAsync(Guid tenantId, CancellationToken cancellationToken)
|
|
{
|
|
var cutoff = DateTimeOffset.UtcNow - RecentExportWindow;
|
|
return jobsOperationsPersistence.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)
|
|
{
|
|
return await tenancyPersistence.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 identityPersistence.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);
|
|
await authorizationStateInvalidator.InvalidateTenantAsync(tenantId, cancellationToken);
|
|
}
|
|
|
|
private static TenantLifecycleOperation CreateOperation(
|
|
Guid tenantId,
|
|
Guid actorUserId,
|
|
TenantLifecycleOperationType type,
|
|
string? reason)
|
|
{
|
|
return new TenantLifecycleOperation
|
|
{
|
|
TenantId = tenantId,
|
|
RequestedBy = actorUserId,
|
|
OperationType = type,
|
|
Reason = reason?.Trim()
|
|
};
|
|
}
|
|
|
|
private void AddAudit(Guid tenantId, Guid actorUserId, string action, Guid targetId, string reason)
|
|
{
|
|
jobsOperationsPersistence.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)
|
|
{
|
|
return provider switch
|
|
{
|
|
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
|
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
|
_ => ObjectStorageProviders.ExternalUrl
|
|
};
|
|
}
|
|
|
|
private static TenantLifecycleOperationItem ToItem(TenantLifecycleOperation operation)
|
|
{
|
|
return new TenantLifecycleOperationItem(
|
|
operation.Id,
|
|
operation.TenantId,
|
|
operation.OperationType,
|
|
operation.Status,
|
|
operation.RequestedBy,
|
|
operation.TargetUserId,
|
|
operation.ExportAssetId,
|
|
operation.Reason,
|
|
operation.LastError,
|
|
operation.CreatedAt,
|
|
operation.CompletedAt);
|
|
}
|
|
}
|