feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -6,11 +6,9 @@ public sealed class CurrentPrincipalMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ICurrentUser currentUser,
ICurrentTenant currentTenant)
ICurrentUser currentUser)
{
currentUser.Load(context.User);
currentTenant.Load(context.User);
await next(context);
}
}

View File

@@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Controllers;
using Tiku.Application.Assets;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Commerce;
using Tiku.Application.Content;
using Tiku.Application.Growth;
using Tiku.Application.Points;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Storage;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
@@ -13,6 +15,7 @@ using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Application.TenantAdmin;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Middleware;
@@ -45,6 +48,52 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is TenantContextConflictException)
{
await WriteProblemAsync(
context,
exception.Message,
StatusCodes.Status403Forbidden,
"tenant_context_conflict");
return;
}
if (exception is TenantFrontendConfigException frontendConfigException)
{
var status = frontendConfigException.Code switch
{
"tenant_not_found" or "frontend_config_not_found" => StatusCodes.Status404NotFound,
"frontend_config_version_conflict" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest
};
await WriteProblemAsync(
context,
frontendConfigException.Message,
status,
frontendConfigException.Code);
return;
}
if (exception is PublicQuestionAccessDeniedException publicQuestionAccessDeniedException)
{
await WriteProblemAsync(
context,
publicQuestionAccessDeniedException.Message,
StatusCodes.Status403Forbidden,
publicQuestionAccessDeniedException.Code);
return;
}
if (exception is QuestionLocatorException questionLocatorException)
{
await WriteProblemAsync(
context,
questionLocatorException.Message,
StatusCodes.Status404NotFound,
questionLocatorException.Code);
return;
}
if (exception is RequiredFieldException)
{
await WriteProblemAsync(

View File

@@ -0,0 +1,74 @@
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Middleware;
public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ITenantDirectory tenantDirectory,
ITenantContextInitializer tenantInitializer,
IOptions<TenantResolutionOptions> options)
{
var host = NormalizeHost(context.Request.Host.Host);
var isPlatformHost = options.Value.PlatformHosts.Any(candidate =>
string.Equals(NormalizeHost(candidate), host, StringComparison.OrdinalIgnoreCase));
TenantDirectoryEntry? tenant = null;
if (!isPlatformHost && host is not null)
{
tenant = await tenantDirectory.FindByHostAsync(host, context.RequestAborted);
if (tenant is null && !IsExemptPath(context.Request.Path, options.Value.ExemptPathPrefixes))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Tenant was not found.",
Status = StatusCodes.Status404NotFound,
Detail = "The request host is not assigned to an active tenant."
}, context.RequestAborted);
return;
}
}
else if (isPlatformHost && IsAllowedTenantCodePath(
context.Request.Path,
options.Value.TenantCodePathPrefixes))
{
var tenantCode = context.Request.Headers["x-tenant-code"].FirstOrDefault()
?? context.Request.Query["tenantCode"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(tenantCode))
{
tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), context.RequestAborted);
}
}
if (tenant is not null)
{
tenantInitializer.Initialize(
tenant.TenantId,
tenant.TenantCode,
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
}
await next(context);
}
private static string? NormalizeHost(string? host)
{
return string.IsNullOrWhiteSpace(host) ? null : host.Trim().TrimEnd('.').ToLowerInvariant();
}
private static bool IsExemptPath(PathString path, IEnumerable<string> prefixes)
{
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
}
private static bool IsAllowedTenantCodePath(PathString path, IEnumerable<string> prefixes)
{
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
}
}