Files
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

495 lines
25 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Backoffice;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Backoffice;
internal sealed class BackofficeService(
IJobsOperationsPersistence jobsOperationsPersistence,
IIdentityPersistence identityPersistence,
IOperationAuditService auditService,
IFeatureAccessService featureAccessService,
IAuthorizationStateInvalidator authorizationStateInvalidator) : IBackofficeService
{
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default)
{
if (!access.IsUserActive || !access.IsCurrentTenantMember ||
access.UserId is null || access.TenantId is null)
throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied");
var permissionCodes = await FilterTenantPermissionCodesAsync(
access.TenantId.Value,
access.TenantPermissions,
CapabilityOperation.Read,
cancellationToken);
var menus = await LoadEffectiveMenusAsync(
BackendPermissionArea.Tenant,
permissionCodes,
cancellationToken);
var features = await featureAccessService.GetEnabledFeaturesAsync(
access.TenantId.Value, FeatureAccessOperation.Read, cancellationToken);
var quotas = await featureAccessService.GetQuotaSummaryAsync(access.TenantId.Value, cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(),
quotas);
}
public async Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default)
{
if (!access.IsUserActive || access.UserId is null || access.PlatformPermissions.Count == 0)
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray();
var menus = await LoadEffectiveMenusAsync(
BackendPermissionArea.Platform,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus, [], []);
}
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
var roles = await LoadTenantRolesAsync(tenantId, cancellationToken);
var permissions = await jobsOperationsPersistence.BackendPermissions.AsNoTracking()
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var enabledPermissionCodes = await FilterTenantPermissionCodesAsync(
tenantId,
permissions.Select(item => item.Code),
CapabilityOperation.Read,
cancellationToken);
permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal))
.ToArray();
var menus = await jobsOperationsPersistence.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
menus = menus.Where(item => item.PermissionCode is null ||
enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal))
.ToArray();
return new BackofficeBootstrap(
permissions.Select(ToPermissionItem).ToArray(),
menus.Select(ToMenuItem).ToArray(),
roles);
}
public async Task<BackofficeBootstrap> GetPlatformBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
var roles = await LoadPlatformRolesAsync(cancellationToken);
var permissions = await jobsOperationsPersistence.BackendPermissions.AsNoTracking()
.Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both)
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var menus = await jobsOperationsPersistence.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform)
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
return new BackofficeBootstrap(
permissions.Select(ToPermissionItem).ToArray(),
menus.Select(ToMenuItem).ToArray(),
roles);
}
public async Task<BackofficeRoleItem> UpsertTenantRoleAsync(
BackofficeActor actor,
UpsertBackofficeRoleCommand command,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
var role = command.Id.HasValue
? await jobsOperationsPersistence.TenantBackendRoles.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == command.Id.Value,
cancellationToken)
: await jobsOperationsPersistence.TenantBackendRoles.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Code == NormalizeCode(command.Code),
cancellationToken);
if (role is null)
{
role = new TenantBackendRole { TenantId = tenantId, Code = NormalizeCode(command.Code) };
jobsOperationsPersistence.TenantBackendRoles.Add(role);
}
else if (role.IsSystem)
{
throw new BackofficeException("System tenant role cannot be modified.", "system_role_locked");
}
role.Name = command.Name.Trim();
role.Status = command.Status;
role.Description = command.Description?.Trim();
role.DataScope = command.DataScope ?? JsonDefaults.Object();
await jobsOperationsPersistence.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code },
cancellationToken);
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
}
public async Task<BackofficeRoleItem> UpsertPlatformRoleAsync(
BackofficeActor actor,
UpsertBackofficeRoleCommand command,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
var role = command.Id.HasValue
? await jobsOperationsPersistence.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value,
cancellationToken)
: await jobsOperationsPersistence.PlatformBackendRoles.SingleOrDefaultAsync(
item => item.Code == NormalizeCode(command.Code), cancellationToken);
if (role is null)
{
role = new PlatformBackendRole { Code = NormalizeCode(command.Code) };
jobsOperationsPersistence.PlatformBackendRoles.Add(role);
}
else if (role.IsSystem)
{
throw new BackofficeException("System platform role cannot be modified.", "system_role_locked");
}
role.Name = command.Name.Trim();
role.Status = command.Status;
role.Description = command.Description?.Trim();
await jobsOperationsPersistence.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code },
cancellationToken);
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
}
public async Task<BackofficeRoleItem> ReplaceTenantRoleBindingsAsync(
BackofficeActor actor,
ReplaceRoleBindingsCommand command,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
var role = await jobsOperationsPersistence.TenantBackendRoles.SingleOrDefaultAsync(
item => item.TenantId == tenantId && item.Id == command.RoleId,
cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found");
if (role.IsSystem)
throw new BackofficeException("System tenant role bindings cannot be modified.", "system_role_locked");
await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes,
cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code },
cancellationToken);
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
}
public async Task<BackofficeRoleItem> ReplacePlatformRoleBindingsAsync(
BackofficeActor actor,
ReplaceRoleBindingsCommand command,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
var role = await jobsOperationsPersistence.PlatformBackendRoles.SingleOrDefaultAsync(
item => item.Id == command.RoleId,
cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found");
if (role.IsSystem)
throw new BackofficeException("System platform role bindings cannot be modified.", "system_role_locked");
await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code },
cancellationToken);
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
}
public async Task ReplaceTenantUserRolesAsync(
BackofficeActor actor,
ReplaceUserRolesCommand command,
CancellationToken cancellationToken = default)
{
var tenantId = RequireTenantAdmin(actor);
var roleIds = command.RoleIds.Distinct().ToArray();
var ownerRoleId = await jobsOperationsPersistence.TenantBackendRoles
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner" && item.IsSystem)
.Select(item => (Guid?)item.Id)
.SingleOrDefaultAsync(cancellationToken);
var isActiveOwner = await identityPersistence.TenantMemberships.AnyAsync(
item => item.TenantId == tenantId &&
item.UserId == command.UserId &&
item.Role == TenantRole.TenantOwner &&
item.Status == MembershipStatus.Active,
cancellationToken);
if (isActiveOwner && ownerRoleId.HasValue && !roleIds.Contains(ownerRoleId.Value))
throw new BackofficeException("Tenant owner system role cannot be removed.", "system_role_locked");
var count = await jobsOperationsPersistence.TenantBackendRoles.CountAsync(
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
cancellationToken);
if (count != roleIds.Length)
throw new BackofficeException("One or more tenant roles were not found.", "role_not_found");
await jobsOperationsPersistence.TenantBackendUserRoles
.Where(item => item.TenantId == tenantId && item.UserId == command.UserId)
.ExecuteDeleteAsync(cancellationToken);
jobsOperationsPersistence.TenantBackendUserRoles.AddRange(roleIds.Select(roleId => new TenantBackendUserRole
{
TenantId = tenantId,
UserId = command.UserId,
RoleId = roleId
}));
await jobsOperationsPersistence.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds },
cancellationToken);
}
public async Task ReplacePlatformUserRolesAsync(
BackofficeActor actor,
ReplaceUserRolesCommand command,
CancellationToken cancellationToken = default)
{
RequirePlatformAdmin(actor);
var roleIds = command.RoleIds.Distinct().ToArray();
var count = await jobsOperationsPersistence.PlatformBackendRoles.CountAsync(
item => roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
cancellationToken);
if (count != roleIds.Length)
throw new BackofficeException("One or more platform roles were not found.", "role_not_found");
await jobsOperationsPersistence.PlatformBackendUserRoles
.Where(item => item.UserId == command.UserId)
.ExecuteDeleteAsync(cancellationToken);
jobsOperationsPersistence.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
{
UserId = command.UserId,
RoleId = roleId
}));
await jobsOperationsPersistence.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds },
cancellationToken);
}
private async Task ReplaceTenantBindingsCoreAsync(
Guid tenantId,
Guid roleId,
IReadOnlyCollection<string> permissionCodes,
IReadOnlyCollection<string> menuCodes,
CancellationToken cancellationToken)
{
var normalizedPermissions = NormalizeCodes(permissionCodes);
var normalizedMenus = NormalizeCodes(menuCodes);
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
var allowedPermissions = await featureAccessService.FilterPermissionCodesAsync(
tenantId, normalizedPermissions, FeatureAccessOperation.Write, cancellationToken);
if (allowedPermissions.Count != normalizedPermissions.Length)
throw new BackofficeException(
"One or more permissions belong to a feature unavailable to this tenant.",
"feature_not_available");
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
await jobsOperationsPersistence.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId)
.ExecuteDeleteAsync(cancellationToken);
await jobsOperationsPersistence.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId)
.ExecuteDeleteAsync(cancellationToken);
jobsOperationsPersistence.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code =>
new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code }));
jobsOperationsPersistence.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu
{ TenantId = tenantId, RoleId = roleId, MenuCode = code }));
await jobsOperationsPersistence.SaveChangesAsync(cancellationToken);
}
private async Task ReplacePlatformBindingsCoreAsync(
Guid roleId,
IReadOnlyCollection<string> permissionCodes,
IReadOnlyCollection<string> menuCodes,
CancellationToken cancellationToken)
{
var normalizedPermissions = NormalizeCodes(permissionCodes);
var normalizedMenus = NormalizeCodes(menuCodes);
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Platform, cancellationToken);
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Platform, cancellationToken);
await jobsOperationsPersistence.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId)
.ExecuteDeleteAsync(cancellationToken);
await jobsOperationsPersistence.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId)
.ExecuteDeleteAsync(cancellationToken);
jobsOperationsPersistence.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code =>
new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }));
jobsOperationsPersistence.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu
{ RoleId = roleId, MenuCode = code }));
await jobsOperationsPersistence.SaveChangesAsync(cancellationToken);
}
private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area,
CancellationToken cancellationToken)
{
var count = await jobsOperationsPersistence.BackendPermissions.CountAsync(
item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both),
cancellationToken);
if (count != codes.Length)
throw new BackofficeException("One or more permissions were not found.", "permission_not_found");
}
private async Task<BackofficeMenuItem[]> LoadEffectiveMenusAsync(
BackendPermissionArea area,
IReadOnlyCollection<string> permissionCodes,
CancellationToken cancellationToken)
{
var codes = permissionCodes.ToArray();
var menus = await jobsOperationsPersistence.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == area &&
(item.PermissionCode == null || codes.Contains(item.PermissionCode)))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
return menus.Select(ToMenuItem).ToArray();
}
private async Task<string[]> FilterTenantPermissionCodesAsync(
Guid tenantId,
IEnumerable<string> permissionCodes,
CapabilityOperation operation,
CancellationToken cancellationToken)
{
var featureOperation = operation == CapabilityOperation.Read
? FeatureAccessOperation.Read
: FeatureAccessOperation.Write;
var enabled = await featureAccessService.FilterPermissionCodesAsync(
tenantId, permissionCodes, featureOperation, cancellationToken);
return enabled.Order(StringComparer.Ordinal).ToArray();
}
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area,
CancellationToken cancellationToken)
{
var count = await jobsOperationsPersistence.BackendMenus.CountAsync(
item => codes.Contains(item.Code) && item.Area == area && item.IsActive,
cancellationToken);
if (count != codes.Length) throw new BackofficeException("One or more menus were not found.", "menu_not_found");
}
private async Task<BackofficeRoleItem[]> LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken)
{
var roles = await jobsOperationsPersistence.TenantBackendRoles.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.OrderBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var roleIds = roles.Select(item => item.Id).ToArray();
var permissions = await jobsOperationsPersistence.TenantBackendRolePermissions.AsNoTracking()
.Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
var menus = await jobsOperationsPersistence.TenantBackendRoleMenus.AsNoTracking()
.Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
return roles.Select(role => ToTenantRoleItem(role, permissions, menus)).ToArray();
}
private async Task<BackofficeRoleItem[]> LoadPlatformRolesAsync(CancellationToken cancellationToken)
{
var roles = await jobsOperationsPersistence.PlatformBackendRoles.AsNoTracking()
.OrderBy(item => item.Code)
.ToArrayAsync(cancellationToken);
var roleIds = roles.Select(item => item.Id).ToArray();
var permissions = await jobsOperationsPersistence.PlatformBackendRolePermissions.AsNoTracking()
.Where(item => roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
var menus = await jobsOperationsPersistence.PlatformBackendRoleMenus.AsNoTracking()
.Where(item => roleIds.Contains(item.RoleId))
.ToArrayAsync(cancellationToken);
return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray();
}
private async Task<BackofficeRoleItem> LoadTenantRoleAsync(Guid tenantId, Guid roleId,
CancellationToken cancellationToken)
{
return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId);
}
private async Task<BackofficeRoleItem> LoadPlatformRoleAsync(Guid roleId, CancellationToken cancellationToken)
{
return (await LoadPlatformRolesAsync(cancellationToken)).Single(item => item.Id == roleId);
}
private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId,
object details, CancellationToken cancellationToken)
{
await auditService.WriteAsync(new BackofficeOperationAuditCommand(
actor.TenantId,
actor.UserId,
action,
targetType,
targetId.ToString(),
JsonSerializer.SerializeToElement(details)), cancellationToken);
}
private static Guid RequireTenantAdmin(BackofficeActor actor)
{
if (actor.TenantId is not { } tenantId)
throw new BackofficeException("Tenant backoffice requires a resolved tenant.", "tenant_required");
return tenantId;
}
private static void RequirePlatformAdmin(BackofficeActor actor)
{
if (!actor.IsPlatform)
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
}
private static string NormalizeCode(string code)
{
return code.Trim().ToLowerInvariant();
}
private static string[] NormalizeCodes(IEnumerable<string> codes)
{
return codes.Select(NormalizeCode).Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
}
private static BackofficePermissionItem ToPermissionItem(BackendPermission item)
{
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode,
item.Description, item.SortOrder);
}
private static BackofficeMenuItem ToMenuItem(BackendMenu item)
{
return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon,
item.PermissionCode, item.SortOrder, item.IsActive);
}
private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role,
TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus)
{
return new BackofficeRoleItem(
role.Id,
role.Code,
role.Name,
role.Status,
role.IsSystem,
role.Description,
permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray(),
role.DataScope);
}
private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role,
PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus)
{
return new BackofficeRoleItem(
role.Id,
role.Code,
role.Name,
role.Status,
role.IsSystem,
role.Description,
permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray());
}
}