diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs index 0a2193a..c2bd263 100644 --- a/Tiku.Api/Configuration/ApiPresentationExtensions.cs +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -27,6 +27,7 @@ internal static class ApiPresentationExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index b1699cb..966dee5 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -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) : 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 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."); - } } diff --git a/Tiku.Api/Controllers/AuthRequestContextResolver.cs b/Tiku.Api/Controllers/AuthRequestContextResolver.cs new file mode 100644 index 0000000..4b86901 --- /dev/null +++ b/Tiku.Api/Controllers/AuthRequestContextResolver.cs @@ -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) +{ + 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 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."); + } +} diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index e9fca64..17f9415 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -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);