feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

@@ -3,18 +3,21 @@ using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Commerce;
internal sealed class CommerceAdminService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector,
ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService
ITenantExternalProviderConfigService providerConfigService,
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
{
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor,
@@ -105,8 +108,14 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var orders = dbContext.Orders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (!string.IsNullOrWhiteSpace(query.Status))
{
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
@@ -125,8 +134,16 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var scopedOrders = dbContext.Orders.AsNoTracking()
.Where(order => order.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
order => order.UserId == actor.UserId,
order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value));
var payments = from payment in dbContext.Payments.AsNoTracking()
join order in dbContext.Orders.AsNoTracking()
join order in scopedOrders
on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
where payment.TenantId == actor.TenantId
select new { payment, order.OrderNo };
@@ -568,8 +585,19 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)));
if (!string.IsNullOrWhiteSpace(query.Status))
{
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
@@ -588,9 +616,16 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var order = await dbContext.Orders.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found");
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var order = await dbContext.Orders
.Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
.SingleOrDefaultAsync(cancellationToken)
?? throw new CommerceException("Order was not found.", "order_not_found");
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
{
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
@@ -639,9 +674,21 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId,
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refund = await dbContext.CommerceRefundRequests
.Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)))
.SingleOrDefaultAsync(cancellationToken)
?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var fromStatus = refund.Status;
if (!IsAllowedRefundTransition(fromStatus, command.Status))
{
@@ -687,6 +734,25 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refundExists = await dbContext.CommerceRefundRequests
.Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)))
.AnyAsync(cancellationToken);
if (!refundExists)
{
throw new CommerceException("Refund request was not found.", "refund_not_found");
}
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
.OrderBy(item => item.CreatedAt)
@@ -807,20 +873,24 @@ internal sealed class CommerceAdminService(
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active &&
(item.Role == TenantRole.PlatformAdmin ||
item.Role == TenantRole.TenantOwner ||
item.Role == TenantRole.TenantAdmin),
cancellationToken);
if (!isAdmin)
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
{
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
}
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken)
{
await AssertAdminAsync(actor, cancellationToken);
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
}
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
new(
item.Id,