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

@@ -0,0 +1,66 @@
using Npgsql;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Tenancy;
public sealed class TenantDirectory(NpgsqlDataSource dataSource) : ITenantDirectory
{
public Task<TenantDirectoryEntry?> FindByHostAsync(
string host,
CancellationToken cancellationToken = default)
{
const string sql = """
select t.id, t.slug, t.name, t.status, t.mode, d.host
from tenant_domains d
join tenants t on t.id = d.tenant_id
where d.host = @lookup
and d.status = 'active'
and t.status = 'active'
limit 1
""";
return FindAsync(sql, host, cancellationToken);
}
public Task<TenantDirectoryEntry?> FindByCodeAsync(
string tenantCode,
CancellationToken cancellationToken = default)
{
const string sql = """
select t.id, t.slug, t.name, t.status, t.mode, null::text as host
from tenants t
where t.slug = @lookup
and t.status = 'active'
limit 1
""";
return FindAsync(sql, tenantCode, cancellationToken);
}
private async Task<TenantDirectoryEntry?> FindAsync(
string sql,
string lookup,
CancellationToken cancellationToken)
{
await using var command = dataSource.CreateCommand(sql);
command.Parameters.AddWithValue("lookup", lookup);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new TenantDirectoryEntry(
reader.GetGuid(0),
reader.GetString(1),
reader.GetString(2),
ParseEnum<TenantStatus>(reader.GetString(3)),
ParseEnum<TenantMode>(reader.GetString(4)),
reader.IsDBNull(5) ? null : reader.GetString(5));
}
private static TEnum ParseEnum<TEnum>(string value)
where TEnum : struct, Enum
{
return Enum.Parse<TEnum>(value.Replace("_", string.Empty, StringComparison.Ordinal), true);
}
}

View File

@@ -0,0 +1,196 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Tenancy;
public sealed class DnsDomainOwnershipVerifier(
HttpClient httpClient,
IOptions<DomainLifecycleOptions> options) : IDomainOwnershipVerifier
{
private readonly DomainLifecycleOptions options = options.Value;
public async Task<DomainOwnershipResult> VerifyAsync(
string host,
string verificationToken,
CancellationToken cancellationToken = default)
{
if (options.AllowedCnameTargets.Length == 0 || string.IsNullOrWhiteSpace(options.DnsJsonEndpoint))
{
return new(false, false, "DNS verification is not configured.");
}
try
{
var cnameAnswers = await QueryAsync(host, "CNAME", cancellationToken);
var cnameMatches = cnameAnswers.Any(answer => options.AllowedCnameTargets.Any(target =>
NormalizeDnsName(answer).Equals(NormalizeDnsName(target), StringComparison.OrdinalIgnoreCase)));
if (!cnameMatches)
{
return new(false, true, "CNAME does not point to an allowed gateway target.");
}
var verificationName = $"{options.VerificationRecordPrefix.Trim().TrimEnd('.')}.{host}";
var txtAnswers = await QueryAsync(verificationName, "TXT", cancellationToken);
var txtMatches = txtAnswers.Any(answer =>
answer.Trim().Trim('"').Equals(verificationToken, StringComparison.Ordinal));
return txtMatches
? new(true, true, null)
: new(false, true, "TXT ownership token was not found.");
}
catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException)
{
return new(false, true, $"DNS verification failed: {exception.Message}");
}
}
private async Task<string[]> QueryAsync(string name, string type, CancellationToken cancellationToken)
{
var endpoint = options.DnsJsonEndpoint.TrimEnd('/');
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{endpoint}?name={Uri.EscapeDataString(name)}&type={type}");
request.Headers.Accept.ParseAdd("application/dns-json");
using var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
if (!document.RootElement.TryGetProperty("Answer", out var answers) || answers.ValueKind != JsonValueKind.Array)
{
return [];
}
return answers.EnumerateArray()
.Where(answer => answer.TryGetProperty("data", out _))
.Select(answer => answer.GetProperty("data").GetString() ?? string.Empty)
.Where(answer => answer.Length > 0)
.ToArray();
}
private static string NormalizeDnsName(string value) => value.Trim().Trim('"').TrimEnd('.');
}
public sealed class HttpDomainGatewayProvisioner(
HttpClient httpClient,
IOptions<DomainLifecycleOptions> options) : IDomainGatewayProvisioner
{
private readonly DomainLifecycleOptions options = options.Value;
public async Task<DomainGatewayResult> EnsureTlsAsync(
string host,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(options.GatewayBaseUrl) || string.IsNullOrWhiteSpace(options.GatewayApiKey))
{
return new(false, false, "Gateway TLS provisioning is not configured.");
}
try
{
using var request = new HttpRequestMessage(
HttpMethod.Post,
$"{options.GatewayBaseUrl.TrimEnd('/')}/domains/ensure");
request.Headers.Authorization = new("Bearer", options.GatewayApiKey);
request.Content = JsonContent.Create(new { host });
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new(false, true, $"Gateway returned HTTP {(int)response.StatusCode}.");
}
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
var tlsReady = document.RootElement.TryGetProperty("tlsReady", out var value) && value.GetBoolean();
return tlsReady
? new(true, true, null)
: new(false, true, "Gateway route exists but TLS is not ready.");
}
catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException)
{
return new(false, true, $"Gateway provisioning failed: {exception.Message}");
}
}
}
public sealed class TenantRuntimeCacheInvalidator(IMemoryCache cache) : ITenantRuntimeCacheInvalidator
{
public void Invalidate(Guid tenantId) => cache.Remove($"tenant-runtime:{tenantId:N}");
}
public sealed class TenantDomainLifecycleService(
TikuDbContext dbContext,
IDomainOwnershipVerifier ownershipVerifier,
IDomainGatewayProvisioner gatewayProvisioner,
ITenantRuntimeCacheInvalidator cacheInvalidator,
IOptions<DomainLifecycleOptions> options) : ITenantDomainLifecycleService
{
private readonly DomainLifecycleOptions options = options.Value;
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken = default)
{
if (!options.Enabled)
{
return 0;
}
var domains = await dbContext.TenantDomains
.Where(domain =>
domain.DomainType == TenantDomainType.Custom &&
(domain.Status == TenantDomainStatus.Pending || domain.Status == TenantDomainStatus.Failed))
.OrderBy(domain => domain.LastCheckedAt)
.Take(Math.Clamp(options.BatchSize, 1, 500))
.ToArrayAsync(cancellationToken);
foreach (var domain in domains)
{
await ProcessAsync(domain, cancellationToken);
}
if (domains.Length > 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
return domains.Length;
}
private async Task ProcessAsync(TenantDomain domain, CancellationToken cancellationToken)
{
domain.LastCheckedAt = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(domain.VerificationToken))
{
Fail(domain, true, "Domain verification token is missing.");
return;
}
var ownership = await ownershipVerifier.VerifyAsync(domain.Host, domain.VerificationToken, cancellationToken);
if (!ownership.Verified)
{
Fail(domain, ownership.Configured, ownership.FailureReason);
return;
}
domain.DnsVerifiedAt ??= DateTimeOffset.UtcNow;
domain.VerifiedAt ??= domain.DnsVerifiedAt;
var gateway = await gatewayProvisioner.EnsureTlsAsync(domain.Host, cancellationToken);
if (!gateway.TlsReady)
{
Fail(domain, gateway.Configured, gateway.FailureReason);
return;
}
domain.TlsReadyAt ??= DateTimeOffset.UtcNow;
domain.Status = TenantDomainStatus.Active;
domain.LastFailureReason = null;
cacheInvalidator.Invalidate(domain.TenantId);
}
private static void Fail(TenantDomain domain, bool configured, string? reason)
{
domain.Status = configured ? TenantDomainStatus.Failed : TenantDomainStatus.Pending;
domain.LastFailureReason = reason;
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Tiku.Application.Security;
namespace Tiku.Infrastructure.Tenancy;
public sealed class TenantExecutionScope(
IServiceScopeFactory scopeFactory,
ILogger<TenantExecutionScope> logger) : ITenantExecutionScope
{
public Task ExecuteAsync(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task> operation,
CancellationToken cancellationToken = default)
{
return ExecuteAsync<object?>(
targetTenantId,
reason,
async (provider, token) =>
{
await operation(provider, token);
return null;
},
cancellationToken);
}
public async Task<TResult> ExecuteAsync<TResult>(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(operation);
if (string.IsNullOrWhiteSpace(reason))
{
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
}
await using var scope = scopeFactory.CreateAsyncScope();
var initializer = scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>();
initializer.InitializeSystem(targetTenantId, reason);
logger.LogWarning(
"Entering audited system tenant scope. TargetTenantId={TargetTenantId} Reason={Reason}",
targetTenantId,
reason);
return await operation(scope.ServiceProvider, cancellationToken);
}
}

View File

@@ -0,0 +1,207 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Tiku.Application.Tenancy;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Tenancy;
public sealed class TenantFrontendConfigService(
TikuDbContext dbContext,
IMemoryCache cache) : ITenantFrontendConfigService
{
private static readonly TimeSpan RuntimeCacheDuration = TimeSpan.FromMinutes(2);
public async Task<TenantFrontendConfigItem> GetAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var config = await dbContext.TenantFrontendConfigs.AsNoTracking()
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
return ToItem(config ?? CreateDefault(tenantId));
}
public async Task<TenantFrontendConfigItem> SaveDraftAsync(
Guid tenantId,
TenantFrontendConfigDraft draft,
CancellationToken cancellationToken = default)
{
Validate(draft);
var config = await dbContext.TenantFrontendConfigs
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
if (config is null)
{
config = CreateDefault(tenantId);
dbContext.TenantFrontendConfigs.Add(config);
}
config.DraftBranding = draft.Branding.Clone();
config.DraftTheme = draft.Theme.Clone();
config.DraftFeatures = draft.Features.Clone();
config.DraftNavigation = draft.Navigation.Clone();
config.DraftHomeModules = draft.HomeModules.Clone();
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(config);
}
public async Task<TenantFrontendConfigItem> PublishAsync(
Guid tenantId,
int expectedVersion,
CancellationToken cancellationToken = default)
{
var config = await dbContext.TenantFrontendConfigs
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken)
?? throw new TenantFrontendConfigException(
"frontend_config_not_found",
"Save a frontend configuration draft before publishing.");
if (config.ConfigVersion != expectedVersion)
{
throw new TenantFrontendConfigException(
"frontend_config_version_conflict",
"Frontend configuration has changed; reload it before publishing.");
}
var draft = Draft(config);
Validate(draft);
config.PublishedBranding = config.DraftBranding.Clone();
config.PublishedTheme = config.DraftTheme.Clone();
config.PublishedFeatures = config.DraftFeatures.Clone();
config.PublishedNavigation = config.DraftNavigation.Clone();
config.PublishedHomeModules = config.DraftHomeModules.Clone();
config.ConfigVersion++;
config.PublishedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
cache.Remove(CacheKey(tenantId));
return ToItem(config);
}
public async Task<TenantRuntimeBootstrap> GetRuntimeAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
if (cache.TryGetValue<TenantRuntimeBootstrap>(CacheKey(tenantId), out var cached) && cached is not null)
{
return cached;
}
var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync(
item => item.Id == tenantId && item.Status == TenantStatus.Active,
cancellationToken)
?? throw new TenantFrontendConfigException("tenant_not_found", "Active tenant was not found.");
var config = await dbContext.TenantFrontendConfigs.AsNoTracking()
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken)
?? CreateDefault(tenantId);
var result = new TenantRuntimeBootstrap(
config.SchemaVersion,
config.ConfigVersion,
tenant.Slug,
tenant.Name,
config.PublishedBranding.Clone(),
config.PublishedTheme.Clone(),
config.PublishedFeatures.Clone(),
config.PublishedNavigation.Clone(),
config.PublishedHomeModules.Clone());
cache.Set(CacheKey(tenantId), result, RuntimeCacheDuration);
return result;
}
private static TenantFrontendConfig CreateDefault(Guid tenantId)
{
return new TenantFrontendConfig
{
TenantId = tenantId,
SchemaVersion = 1,
ConfigVersion = 1
};
}
private static TenantFrontendConfigItem ToItem(TenantFrontendConfig config)
{
return new TenantFrontendConfigItem(
config.SchemaVersion,
config.ConfigVersion,
new TenantFrontendConfigDraft(
config.PublishedBranding.Clone(),
config.PublishedTheme.Clone(),
config.PublishedFeatures.Clone(),
config.PublishedNavigation.Clone(),
config.PublishedHomeModules.Clone()),
Draft(config),
config.PublishedAt);
}
private static TenantFrontendConfigDraft Draft(TenantFrontendConfig config)
{
return new TenantFrontendConfigDraft(
config.DraftBranding.Clone(),
config.DraftTheme.Clone(),
config.DraftFeatures.Clone(),
config.DraftNavigation.Clone(),
config.DraftHomeModules.Clone());
}
private static void Validate(TenantFrontendConfigDraft draft)
{
RequireKind(draft.Branding, JsonValueKind.Object, "branding");
RequireKind(draft.Theme, JsonValueKind.Object, "theme");
RequireKind(draft.Features, JsonValueKind.Object, "features");
RequireKind(draft.Navigation, JsonValueKind.Array, "navigation");
RequireKind(draft.HomeModules, JsonValueKind.Array, "homeModules");
foreach (var root in new[] { draft.Branding, draft.Theme, draft.Features, draft.Navigation, draft.HomeModules })
{
RejectUnsafeContent(root);
}
}
private static void RequireKind(JsonElement value, JsonValueKind expected, string field)
{
if (value.ValueKind != expected)
{
throw new TenantFrontendConfigException(
"frontend_config_invalid",
$"Frontend configuration field '{field}' must be a JSON {expected.ToString().ToLowerInvariant()}.");
}
}
private static void RejectUnsafeContent(JsonElement value)
{
if (value.ValueKind == JsonValueKind.Object)
{
foreach (var property in value.EnumerateObject())
{
if (property.Name.Contains("script", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("html", StringComparison.OrdinalIgnoreCase))
{
throw UnsafeConfig();
}
RejectUnsafeContent(property.Value);
}
}
else if (value.ValueKind == JsonValueKind.Array)
{
foreach (var item in value.EnumerateArray())
{
RejectUnsafeContent(item);
}
}
else if (value.ValueKind == JsonValueKind.String)
{
var text = value.GetString() ?? string.Empty;
if (text.Contains("<script", StringComparison.OrdinalIgnoreCase) ||
text.Contains("javascript:", StringComparison.OrdinalIgnoreCase))
{
throw UnsafeConfig();
}
}
}
private static TenantFrontendConfigException UnsafeConfig() => new(
"frontend_config_unsafe",
"Frontend configuration cannot contain HTML or executable scripts.");
private static string CacheKey(Guid tenantId) => $"tenant-runtime:{tenantId:N}";
}