test(architecture): enforce capability boundaries

This commit is contained in:
2026-08-04 09:43:58 +08:00
parent c06cf4a4f7
commit e290719d4a
4 changed files with 285 additions and 124 deletions

View File

@@ -27,6 +27,7 @@ internal static class ApiPresentationExtensions
services.AddScoped<CommerceAdminActorResolver>();
services.AddScoped<LearningActorResolver>();
services.AddScoped<PlatformAdminActorResolver>();
services.AddScoped<AuthRequestContextResolver>();
return services;
}

View File

@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Application.Auth;
@@ -22,12 +21,8 @@ public sealed class AuthController(
IAuthService authService,
IOwnerActivationService ownerActivationService,
ISmsVerificationService smsVerificationService,
IAuthSessionStore sessionStore,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory,
ICurrentUser currentUser,
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
AuthRequestContextResolver requestContextResolver,
ICurrentUser currentUser) : ControllerBase
{
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
@@ -58,11 +53,12 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
requestContextResolver.EnsureRouteRealm(realm, Request);
if (realm != AuthRealm.Tenant)
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken)
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
var result = await smsVerificationService.CreateCodeAsync(
new SendSmsCodeRequest(
@@ -88,13 +84,14 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
requestContextResolver.EnsureRouteRealm(realm, Request);
var identifier = request.Identifier ?? request.Phone;
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
var result = await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
cancellationToken),
identifier,
request.Password,
GetIpAddress(),
@@ -116,11 +113,12 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
requestContextResolver.EnsureRouteRealm(realm, Request);
var result = await authService.LoginWithSmsAsync(
new SmsLoginRequest(
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
cancellationToken),
request.Phone,
request.Code,
GetIpAddress(),
@@ -142,11 +140,12 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
requestContextResolver.EnsureRouteRealm(realm, Request);
var result = await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
@@ -167,11 +166,12 @@ public sealed class AuthController(
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
EnsureRouteRealm(realm);
requestContextResolver.EnsureRouteRealm(realm, Request);
var result = await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
realm,
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
@@ -188,7 +188,7 @@ public sealed class AuthController(
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
ResolveRefreshTokenTenant(request.RefreshToken);
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
var result = await authService.RefreshAsync(
new RefreshSessionRequest(
request.RefreshToken,
@@ -208,7 +208,7 @@ public sealed class AuthController(
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
ResolveRefreshTokenTenant(request.RefreshToken);
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
await authService.LogoutAsync(
new LogoutSessionRequest(request.RefreshToken),
cancellationToken);
@@ -238,7 +238,7 @@ public sealed class AuthController(
[FromBody] RequiredPasswordChangeDto request,
CancellationToken cancellationToken)
{
ResolveAuthChallengeTenant(request.ChallengeToken);
requestContextResolver.ResolveAuthChallengeTenant(request.ChallengeToken, Request);
var result = await authService.ChangeRequiredPasswordAsync(
new PasswordChangeChallengeRequest(
request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()),
@@ -256,7 +256,8 @@ public sealed class AuthController(
PasswordResetSmsSendDto request,
CancellationToken cancellationToken)
{
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode,
Request, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
var result = await authService.RequestPasswordResetAsync(
new PasswordResetCodeRequest(
@@ -278,7 +279,8 @@ public sealed class AuthController(
PasswordResetDto request,
CancellationToken cancellationToken)
{
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode,
Request, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
await authService.ResetPasswordAsync(
new PasswordResetRequest(
@@ -315,111 +317,9 @@ public sealed class AuthController(
return Ok(AuthenticationResultDto.FromApplication(result));
}
private void ResolveRefreshTokenTenant(string refreshToken)
{
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator)) return;
if (locator.Realm == AuthRealm.Platform)
{
EnsurePlatformHost();
if (tenantContext.IsResolved)
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
return;
}
if (!tenantContext.IsResolved)
throw new RequiredFieldException(
"tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
}
private void ResolveAuthChallengeTenant(string challengeToken)
{
var parts = challengeToken.Split('.', 4);
if (parts.Length != 4 || parts[0] != "c1") return;
if (parts[1] == "p" && parts[2] == "-")
{
EnsurePlatformHost();
if (tenantContext.IsResolved)
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
return;
}
if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
throw new RequiredFieldException(
"tenant authentication challenge requires a tenant host or x-tenant-code.");
tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
}
private string? GetIpAddress()
{
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<Guid?> ResolveRealmTenantIdAsync(
AuthRealm realm,
string? tenantCode,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Platform)
{
EnsurePlatformHost();
if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
throw new RequiredFieldException(
"platform realm does not accept tenantCode and must use a platform host.");
return null;
}
if (tenantContext.TenantId.HasValue)
{
if (!string.IsNullOrWhiteSpace(tenantCode) &&
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
{
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
if (supplied?.TenantId != tenantContext.TenantId.Value)
throw new TenantContextConflictException(
tenantContext.TenantId.Value,
supplied?.TenantId ?? Guid.Empty);
}
return tenantContext.TenantId.Value;
}
if (string.IsNullOrWhiteSpace(tenantCode))
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantContextInitializer.Initialize(
tenant.TenantId,
tenant.TenantCode,
TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
private void EnsurePlatformHost()
{
var requestHost = Request.Host.Host.Trim().TrimEnd('.');
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
string.Equals(
host.Trim().TrimEnd('.'),
requestHost,
StringComparison.OrdinalIgnoreCase)))
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
}
private void EnsureRouteRealm(AuthRealm realm)
{
var path = Request.Path.Value ?? string.Empty;
var expectedRealm = path.StartsWith("/api/platform/auth/", StringComparison.OrdinalIgnoreCase)
? AuthRealm.Platform
: AuthRealm.Tenant;
if (realm != expectedRealm)
throw new RequiredFieldException(
$"{realm.ToString().ToLowerInvariant()} realm must use the {expectedRealm.ToString().ToLowerInvariant()} authentication route.");
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.Extensions.Options;
using Tiku.Api.Options;
using Tiku.Application.Auth;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
public sealed class AuthRequestContextResolver(
IAuthSessionStore sessionStore,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory,
IOptions<TenantResolutionOptions> tenantResolutionOptions)
{
internal void ResolveRefreshTokenTenant(string refreshToken, HttpRequest request)
{
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator)) return;
if (locator.Realm == AuthRealm.Platform)
{
EnsurePlatformHost(request);
if (tenantContext.IsResolved)
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
return;
}
if (!tenantContext.IsResolved)
throw new RequiredFieldException(
"tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
}
internal void ResolveAuthChallengeTenant(string challengeToken, HttpRequest request)
{
var parts = challengeToken.Split('.', 4);
if (parts.Length != 4 || parts[0] != "c1") return;
if (parts[1] == "p" && parts[2] == "-")
{
EnsurePlatformHost(request);
if (tenantContext.IsResolved)
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
return;
}
if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
throw new RequiredFieldException(
"tenant authentication challenge requires a tenant host or x-tenant-code.");
tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
}
internal async Task<Guid?> ResolveRealmTenantIdAsync(
AuthRealm realm,
string? tenantCode,
HttpRequest request,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Platform)
{
EnsurePlatformHost(request);
if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
throw new RequiredFieldException(
"platform realm does not accept tenantCode and must use a platform host.");
return null;
}
if (tenantContext.TenantId.HasValue)
{
if (!string.IsNullOrWhiteSpace(tenantCode) &&
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
{
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
if (supplied?.TenantId != tenantContext.TenantId.Value)
throw new TenantContextConflictException(
tenantContext.TenantId.Value,
supplied?.TenantId ?? Guid.Empty);
}
return tenantContext.TenantId.Value;
}
if (string.IsNullOrWhiteSpace(tenantCode))
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantContextInitializer.Initialize(
tenant.TenantId,
tenant.TenantCode,
TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
internal void EnsureRouteRealm(AuthRealm realm, HttpRequest request)
{
var path = request.Path.Value ?? string.Empty;
var expectedRealm = path.StartsWith("/api/platform/auth/", StringComparison.OrdinalIgnoreCase)
? AuthRealm.Platform
: AuthRealm.Tenant;
if (realm != expectedRealm)
throw new RequiredFieldException(
$"{realm.ToString().ToLowerInvariant()} realm must use the {expectedRealm.ToString().ToLowerInvariant()} authentication route.");
}
private void EnsurePlatformHost(HttpRequest request)
{
var requestHost = request.Host.Host.Trim().TrimEnd('.');
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
string.Equals(
host.Trim().TrimEnd('.'),
requestHost,
StringComparison.OrdinalIgnoreCase)))
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
}
}

View File

@@ -69,11 +69,106 @@ public sealed class ArchitectureBoundaryTests
: $"{item.Service} ({item.Lines} lines across {item.Files} files; hard limit 800)")
.ToArray();
foreach (var warning in serviceGroups.Where(item =>
!legacyLineBudgets.ContainsKey(item.Service) && item.Lines is > 500 and <= 800))
Console.WriteLine(
$"Architecture warning: {warning.Service} has {warning.Lines} lines across {warning.Files} files.");
Assert.True(
violations.Length == 0,
$"Service classes exceeded the hard limit or grew legacy debt:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
[Fact]
public void Capability_contracts_and_services_remain_focused()
{
var contracts = CapabilityContracts();
var contractViolations = contracts
.Where(contract => contract.GetMethods().Length > 10)
.Select(contract => $"{contract.FullName} ({contract.GetMethods().Length} public methods)")
.ToArray();
var implementations = typeof(Tiku.Infrastructure.Persistence.TikuDbContext).Assembly
.GetTypes()
.Where(type => !type.IsAbstract && contracts.Any(contract => contract.IsAssignableFrom(type)))
.ToArray();
var methodViolations = implementations
.Select(type => new
{
Type = type,
Count = type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
.Count(method => !method.IsSpecialName)
})
.Where(item => item.Count > 10)
.Select(item => $"{item.Type.FullName} ({item.Count} public methods)")
.ToArray();
var dependencyCounts = implementations
.Select(type => new
{
Type = type,
Count = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Select(constructor => constructor.GetParameters().Length)
.DefaultIfEmpty(0)
.Max()
})
.ToArray();
foreach (var warning in dependencyCounts.Where(item => item.Count is > 6 and <= 8))
Console.WriteLine($"Architecture warning: {warning.Type.FullName} has {warning.Count} constructor dependencies.");
var dependencyViolations = dependencyCounts
.Where(item => item.Count > 8)
.Select(item => $"{item.Type.FullName} ({item.Count} constructor dependencies)")
.ToArray();
Assert.True(contractViolations.Length == 0,
$"Capability interfaces exceeded 10 public methods:{Environment.NewLine}{string.Join(Environment.NewLine, contractViolations)}");
Assert.True(methodViolations.Length == 0,
$"Capability services exceeded 10 public methods:{Environment.NewLine}{string.Join(Environment.NewLine, methodViolations)}");
Assert.True(dependencyViolations.Length == 0,
$"Capability services exceeded 8 constructor dependencies:{Environment.NewLine}{string.Join(Environment.NewLine, dependencyViolations)}");
}
[Fact]
public void Controllers_remain_focused_and_legacy_facades_do_not_return()
{
var controllerViolations = typeof(AuthController).Assembly.GetTypes()
.Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
.Select(type => new
{
Type = type,
Count = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.Select(constructor => constructor.GetParameters().Length)
.DefaultIfEmpty(0)
.Max()
})
.Where(item => item.Count > 8)
.Select(item => $"{item.Type.FullName} ({item.Count} constructor dependencies)")
.ToArray();
var legacyNames = new[]
{
"ITenantAdminDirectService",
"TenantAdminDirectService",
"IDirectContentService",
"DirectContentService",
"ICommerceAdminService",
"CommerceAdminService",
"ILearningActivityService",
"LearningActivityService",
"IPlatformAdminService",
"PlatformAdminService"
};
var applicationTypes = typeof(Tiku.Application.TenantAdmin.ITenantAdminDashboardService).Assembly.GetTypes();
var infrastructureTypes = typeof(Tiku.Infrastructure.Persistence.TikuDbContext).Assembly.GetTypes();
var legacyViolations = applicationTypes.Concat(infrastructureTypes)
.Where(type => legacyNames.Contains(type.Name, StringComparer.Ordinal))
.Select(type => type.FullName)
.ToArray();
Assert.True(controllerViolations.Length == 0,
$"Controllers exceeded 8 constructor dependencies:{Environment.NewLine}{string.Join(Environment.NewLine, controllerViolations)}");
Assert.True(legacyViolations.Length == 0,
$"Legacy aggregate services returned:{Environment.NewLine}{string.Join(Environment.NewLine, legacyViolations)}");
}
[Fact]
public void Background_jobs_are_dispatched_by_module_registered_handlers()
{
@@ -403,6 +498,50 @@ public sealed class ArchitectureBoundaryTests
$"Business-layer third-party provider SDK references were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
}
private static Type[] CapabilityContracts()
{
return
[
typeof(Tiku.Application.TenantAdmin.ITenantAdminDashboardService),
typeof(Tiku.Application.TenantAdmin.ITenantClassService),
typeof(Tiku.Application.TenantAdmin.ITenantStudentService),
typeof(Tiku.Application.TenantAdmin.ITenantSupervisionService),
typeof(Tiku.Application.TenantAdmin.ITenantStudentEngagementService),
typeof(Tiku.Application.TenantAdmin.ITenantMemberAccessService),
typeof(Tiku.Application.TenantAdmin.ITenantSiteSettingsService),
typeof(Tiku.Application.TenantAdmin.ITenantDomainProviderService),
typeof(Tiku.Application.TenantAdmin.ITenantEngagementService),
typeof(Tiku.Application.Content.IQuestionManagementService),
typeof(Tiku.Application.Content.IVocabularyManagementService),
typeof(Tiku.Application.Content.IHandbookManagementService),
typeof(Tiku.Application.Content.IEducationCatalogManagementService),
typeof(Tiku.Application.Content.IScorelineManagementService),
typeof(Tiku.Application.Content.IVideoManagementService),
typeof(Tiku.Application.Content.IOperationContentManagementService),
typeof(Tiku.Application.Content.IContentImportService),
typeof(Tiku.Application.Commerce.IPaymentConfigurationService),
typeof(Tiku.Application.Commerce.ICommerceOrderAdministrationService),
typeof(Tiku.Application.Commerce.IActivationCodeAdministrationService),
typeof(Tiku.Application.Commerce.ICouponAdministrationService),
typeof(Tiku.Application.Commerce.IRefundAdministrationService),
typeof(Tiku.Application.Commerce.IReconciliationAdministrationService),
typeof(Tiku.Application.Commerce.ICommerceAdjustmentService),
typeof(Tiku.Application.Points.IPointAdministrationService),
typeof(Tiku.Application.Learning.ILearningAnalyticsService),
typeof(Tiku.Application.Learning.IAnsweringService),
typeof(Tiku.Application.Learning.IQuestionReviewService),
typeof(Tiku.Application.Learning.IWordLearningService),
typeof(Tiku.Application.Learning.IPracticeSessionService),
typeof(Tiku.Application.Learning.IPracticeReportService),
typeof(Tiku.Application.PlatformAdmin.IPlatformDashboardService),
typeof(Tiku.Application.PlatformAdmin.ITenantProvisioningAdministrationService),
typeof(Tiku.Application.PlatformAdmin.IPlatformTenantDomainService),
typeof(Tiku.Application.PlatformAdmin.IPlatformStaffAccessService),
typeof(Tiku.Application.PlatformAdmin.IPlatformAuditAlertService),
typeof(Tiku.Application.PlatformAdmin.IPlatformDunningService)
];
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);