200 lines
10 KiB
C#
200 lines
10 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Catalog;
|
|
using Tiku.Application.Content;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Application.TenantAdmin;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Tenancy;
|
|
|
|
namespace Tiku.Infrastructure.TenantAdmin;
|
|
|
|
internal sealed class TenantMemberAccessService(TenantAdminServiceDependencies dependencies)
|
|
: TenantAdminServiceBase(dependencies), ITenantMemberAccessService
|
|
{
|
|
public async Task<CatalogList<TenantAdminMemberItem>> GetMembersAsync(
|
|
TenantAdminActor actor,
|
|
TenantAdminMemberFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await RequireAllDataScopeAsync(actor, cancellationToken);
|
|
var query = identityPersistence.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
|
if (!string.IsNullOrWhiteSpace(filter.Role))
|
|
query = query.Where(item => item.Role == ParseEnum<TenantRole>(filter.Role, "invalid_member_role"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(filter.Status))
|
|
query = query.Where(item =>
|
|
item.Status == ParseEnum<MembershipStatus>(filter.Status, "invalid_member_status"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
|
{
|
|
var keyword = filter.Keyword.Trim();
|
|
query = query.Where(item => identityPersistence.Users.Any(user =>
|
|
user.Id == item.UserId &&
|
|
((user.UserName != null && user.UserName.Contains(keyword)) ||
|
|
(user.Phone != null && user.Phone.Contains(keyword)) ||
|
|
(user.Email != null && user.Email.Contains(keyword)) ||
|
|
(user.Name != null && user.Name.Contains(keyword)))));
|
|
}
|
|
|
|
var memberships = await query
|
|
.OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 :
|
|
item.Role == TenantRole.TenantAdmin ? 1 :
|
|
item.Role == TenantRole.TenantOperator ? 2 :
|
|
item.Role == TenantRole.Teacher ? 3 : 9)
|
|
.ThenBy(item => item.CreatedAt)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken);
|
|
var userIds = memberships.Select(item => item.UserId).ToArray();
|
|
var users = await identityPersistence.Users.AsNoTracking()
|
|
.Where(user => userIds.Contains(user.Id))
|
|
.ToDictionaryAsync(user => user.Id, cancellationToken);
|
|
|
|
return new CatalogList<TenantAdminMemberItem>(memberships.Select(item =>
|
|
{
|
|
users.TryGetValue(item.UserId, out var user);
|
|
return ToMemberItem(item, user ?? new User { Id = item.UserId });
|
|
}).ToArray());
|
|
}
|
|
|
|
public async Task<ContentManagementResult<TenantAdminMemberItem>> UpsertMemberAsync(
|
|
TenantAdminActor actor,
|
|
UpsertTenantAdminMemberCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await RequireAllDataScopeAsync(actor, cancellationToken);
|
|
await using var transaction = unitOfWork.Database.CurrentTransaction is null
|
|
? await unitOfWork.Database.BeginTransactionAsync(cancellationToken)
|
|
: null;
|
|
var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role");
|
|
var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status");
|
|
await AssertGrantableAsync(actor, role, cancellationToken);
|
|
var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role);
|
|
var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken);
|
|
if (user.Id == actor.UserId && status == MembershipStatus.Disabled)
|
|
throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
|
|
|
|
TenantMembership? membership = null;
|
|
if (command.MembershipId.HasValue)
|
|
{
|
|
membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync(
|
|
item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value,
|
|
cancellationToken);
|
|
if (membership is null)
|
|
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
|
|
}
|
|
else
|
|
{
|
|
membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role,
|
|
cancellationToken);
|
|
}
|
|
|
|
var isNew = membership is null;
|
|
var previousStatus = membership?.Status.ToString() ?? "none";
|
|
var wasStaffCounted = await IsUserCountedForMetricAsync(
|
|
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken);
|
|
var wasStudentCounted = await IsUserCountedForMetricAsync(
|
|
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
|
|
Guid? excludedMembershipId = isNew ? null : membership!.Id;
|
|
var otherStaffCounted = await IsUserCountedForMetricAsync(
|
|
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken, excludedMembershipId);
|
|
var otherStudentCounted = await IsUserCountedForMetricAsync(
|
|
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId);
|
|
var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student);
|
|
var willStudentBeCounted =
|
|
otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student);
|
|
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
|
|
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.",
|
|
"tenant_owner_required");
|
|
|
|
if (!wasStaffCounted && willStaffBeCounted)
|
|
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
|
actor.TenantId,
|
|
SaasQuotaMetricCatalog.StaffCount,
|
|
cancellationToken: cancellationToken);
|
|
if (!wasStudentCounted && willStudentBeCounted)
|
|
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
|
actor.TenantId,
|
|
SaasQuotaMetricCatalog.StudentCount,
|
|
cancellationToken: cancellationToken);
|
|
|
|
membership ??= new TenantMembership
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = user.Id
|
|
};
|
|
membership.UserId = user.Id;
|
|
membership.Role = role;
|
|
membership.Status = status;
|
|
if (isNew) identityPersistence.TenantMemberships.Add(membership);
|
|
|
|
if (status != MembershipStatus.Active)
|
|
await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken);
|
|
else if (role == TenantRole.TenantOwner)
|
|
await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken);
|
|
|
|
await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
|
|
await unitOfWork.SaveChangesAsync(cancellationToken);
|
|
if (wasStaffCounted && !willStaffBeCounted)
|
|
await featureAccessService.ReleaseQuotaAsync(
|
|
actor.TenantId,
|
|
SaasQuotaMetricCatalog.StaffCount,
|
|
1,
|
|
cancellationToken);
|
|
if (wasStudentCounted && !willStudentBeCounted)
|
|
await featureAccessService.ReleaseQuotaAsync(
|
|
actor.TenantId,
|
|
SaasQuotaMetricCatalog.StudentCount,
|
|
1,
|
|
cancellationToken);
|
|
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
|
await authorizationStateInvalidator.InvalidateMembershipAsync(
|
|
actor.TenantId, membership.UserId, cancellationToken);
|
|
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken);
|
|
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
|
}
|
|
|
|
public async Task<ContentManagementResult<TenantAdminMemberItem>> DisableMemberAsync(
|
|
TenantAdminActor actor,
|
|
Guid membershipId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await RequireAllDataScopeAsync(actor, cancellationToken);
|
|
await using var transaction = unitOfWork.Database.CurrentTransaction is null
|
|
? await unitOfWork.Database.BeginTransactionAsync(cancellationToken)
|
|
: null;
|
|
var membership = await identityPersistence.TenantMemberships.FirstOrDefaultAsync(
|
|
item => item.TenantId == actor.TenantId && item.Id == membershipId,
|
|
cancellationToken);
|
|
if (membership is null)
|
|
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
|
|
|
|
if (membership.UserId == actor.UserId)
|
|
throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
|
|
|
|
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
|
|
var previousStatus = membership.Status;
|
|
var metricCode = QuotaMetricForRole(membership.Role);
|
|
var wasCounted = await IsUserCountedForMetricAsync(
|
|
actor.TenantId, membership.UserId, metricCode, cancellationToken);
|
|
var otherMembershipCounted = await IsUserCountedForMetricAsync(
|
|
actor.TenantId, membership.UserId, metricCode, cancellationToken, membership.Id);
|
|
membership.Status = MembershipStatus.Disabled;
|
|
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
|
|
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
|
|
await unitOfWork.SaveChangesAsync(cancellationToken);
|
|
if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted)
|
|
await featureAccessService.ReleaseQuotaAsync(
|
|
actor.TenantId,
|
|
metricCode,
|
|
1,
|
|
cancellationToken);
|
|
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
|
await authorizationStateInvalidator.InvalidateMembershipAsync(
|
|
actor.TenantId, membership.UserId, cancellationToken);
|
|
var user = await identityPersistence.Users.AsNoTracking()
|
|
.SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
|
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
|
}
|
|
}
|