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

@@ -1,6 +1,7 @@
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;
@@ -14,19 +15,23 @@ internal sealed class BackofficeService(
{
private static readonly BuiltinPermission[] BuiltinPermissions =
[
new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new(BackendPermissions.TenantContentManage, "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"),
new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"),
new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"),
new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"),
new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
@@ -47,6 +52,43 @@ internal sealed class BackofficeService(
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50)
];
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");
}
await EnsureCatalogAsync(cancellationToken);
var permissionCodes = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray();
var menus = await LoadEffectiveMenusAsync(
BackendPermissionArea.Tenant,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus);
}
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");
}
await EnsureCatalogAsync(cancellationToken);
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)
@@ -193,6 +235,21 @@ internal sealed class BackofficeService(
{
var tenantId = RequireTenantAdmin(actor);
var roleIds = command.RoleIds.Distinct().ToArray();
var ownerRoleId = await dbContext.TenantBackendRoles
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner" && item.IsSystem)
.Select(item => (Guid?)item.Id)
.SingleOrDefaultAsync(cancellationToken);
var isActiveOwner = await dbContext.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 dbContext.TenantBackendRoles.CountAsync(
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
cancellationToken);
@@ -329,6 +386,21 @@ internal sealed class BackofficeService(
}
}
private async Task<BackofficeMenuItem[]> LoadEffectiveMenusAsync(
BackendPermissionArea area,
IReadOnlyCollection<string> permissionCodes,
CancellationToken cancellationToken)
{
var codes = permissionCodes.ToArray();
var menus = await dbContext.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 ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
{
var count = await dbContext.BackendMenus.CountAsync(