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,8 +1,10 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Npgsql;
using System.Text.Json;
using Tiku.Application.Commerce;
using Tiku.Application.Auth;
using Tiku.Application.Growth;
@@ -11,6 +13,7 @@ using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Api;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
@@ -25,12 +28,32 @@ public sealed class ApiTestFactory(
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
IPaymentProviderGateway? paymentProviderGateway = null,
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory<ApiProgramMarker>
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
ISmsProvider? smsProvider = null,
IReadOnlyDictionary<string, string?>? configurationOverrides = null) : WebApplicationFactory<ApiProgramMarker>
{
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
var values = new Dictionary<string, string?>
{
["Security:Jwt:KeyId"] = TestJwtKeys.KeyId,
["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem,
["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api"
};
if (configurationOverrides is not null)
{
foreach (var pair in configurationOverrides)
{
values[pair.Key] = pair.Value;
}
}
configuration.AddInMemoryCollection(values);
});
builder.ConfigureServices(services =>
{
foreach (var descriptor in services
@@ -53,6 +76,8 @@ public sealed class ApiTestFactory(
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
});
services.RemoveAll<IJwtKeyRing>();
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
if (wechatOAuthClient is not null)
{
@@ -85,6 +110,12 @@ public sealed class ApiTestFactory(
services.RemoveAll<IDomainGatewayProvisioner>();
services.AddSingleton(domainGatewayProvisioner);
}
if (smsProvider is not null)
{
services.RemoveAll<ISmsProvider>();
services.AddSingleton(smsProvider);
}
});
}
@@ -96,6 +127,91 @@ public sealed class ApiTestFactory(
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.AddRange(entities);
await dbContext.SaveChangesAsync();
var backendMembers = entities
.OfType<TenantMembership>()
.Where(membership =>
membership.Status == MembershipStatus.Active &&
membership.Role is TenantRole.TenantOwner or TenantRole.TenantAdmin)
.Select(membership => (membership.TenantId, membership.UserId))
.Distinct()
.ToArray();
if (backendMembers.Length > 0)
{
await EnsureTenantBackendAccessAsync(dbContext, backendMembers);
}
}
private static async Task EnsureTenantBackendAccessAsync(
TikuDbContext dbContext,
IEnumerable<(Guid TenantId, Guid UserId)> members)
{
var permissionCodes = BackendPermissions.Tenant.Order(StringComparer.Ordinal).ToArray();
var existingPermissionCodes = await dbContext.BackendPermissions
.Where(permission => permissionCodes.Contains(permission.Code))
.Select(permission => permission.Code)
.ToListAsync();
foreach (var permissionCode in permissionCodes.Except(existingPermissionCodes, StringComparer.Ordinal))
{
dbContext.BackendPermissions.Add(new BackendPermission
{
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Tenant,
Module = permissionCode.Split(':')[1],
IsSystem = true
});
}
foreach (var tenantGroup in members.GroupBy(member => member.TenantId))
{
var tenantId = tenantGroup.Key;
var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(entity =>
entity.TenantId == tenantId && entity.Code == "integration_test_admin");
if (role is null)
{
role = new TenantBackendRole
{
TenantId = tenantId,
Code = "integration_test_admin",
Name = "Integration Test Administrator",
Status = BackendRoleStatus.Active,
IsSystem = true,
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
};
dbContext.TenantBackendRoles.Add(role);
}
var assignedPermissionCodes = await dbContext.TenantBackendRolePermissions
.Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id)
.Select(entity => entity.PermissionCode)
.ToListAsync();
foreach (var permissionCode in permissionCodes.Except(assignedPermissionCodes, StringComparer.Ordinal))
{
dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
{
TenantId = tenantId,
RoleId = role.Id,
PermissionCode = permissionCode
});
}
var assignedUserIds = await dbContext.TenantBackendUserRoles
.Where(entity => entity.TenantId == tenantId && entity.RoleId == role.Id)
.Select(entity => entity.UserId)
.ToListAsync();
foreach (var member in tenantGroup.Where(member => !assignedUserIds.Contains(member.UserId)))
{
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = tenantId,
UserId = member.UserId,
RoleId = role.Id
});
}
}
await dbContext.SaveChangesAsync();
}
public IServiceScope CreateSystemScope(string reason = "Integration test verification")
@@ -151,39 +267,51 @@ public sealed class ApiTestFactory(
public async Task<Guid> SeedActiveSessionAsync(
Guid userId,
Guid? tenantId = null,
string tokenHash = "integration-test-token-hash")
string tokenHash = "integration-test-token-hash",
bool includeMembership = false)
{
var resolvedTenantId = tenantId ?? Guid.NewGuid();
await SeedAsync(
var user = new User
{
Id = userId,
Phone = "13800000000"
};
var session = new AuthSession
{
Id = Guid.NewGuid(),
Realm = AuthRealm.Tenant,
TenantId = resolvedTenantId,
UserId = userId,
TokenFamilyId = Guid.NewGuid(),
TokenHash = tokenHash,
SecurityStamp = user.SecurityStamp ?? string.Empty,
Provider = "test",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
var entities = new List<object>
{
new Tenant
{
Id = resolvedTenantId,
Slug = resolvedTenantId.ToString("N"),
Name = "Test Tenant"
},
new User
user,
session
};
if (includeMembership)
{
entities.Add(new TenantMembership
{
Id = userId,
Phone = "13800000000"
},
new AuthSession
{
Id = Guid.NewGuid(),
TenantId = resolvedTenantId,
UserId = userId,
TokenHash = tokenHash,
Provider = "test",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
}
using var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(resolvedTenantId, "Integration test session lookup");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
return await dbContext.AuthSessions
.Where(session => session.UserId == userId)
.Select(session => session.Id)
.SingleAsync();
await SeedAsync([.. entities]);
return session.Id;
}
protected override void Dispose(bool disposing)