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

@@ -4,7 +4,8 @@ namespace Tiku.Application.Auth;
public interface ISessionService
{
string GenerateRefreshToken();
string GenerateRefreshToken(Guid tenantId, Guid sessionId);
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
string HashRefreshToken(string refreshToken);
Task<AuthTokenPair> IssueAsync(
@@ -17,3 +18,5 @@ public interface ISessionService
string? userAgent,
CancellationToken cancellationToken = default);
}
public readonly record struct RefreshTokenLocator(Guid TenantId, Guid SessionId);

View File

@@ -0,0 +1,36 @@
using System.Text.Json;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
namespace Tiku.Application.Catalog;
public sealed record TaxonomyNodeItem(
Guid Id,
QuestionSource Source,
Guid? ParentId,
QuestionSource? ParentSource,
TaxonomyNodeType NodeType,
string Code,
string Name,
string? Path,
int Depth,
int SortOrder,
JsonElement Metadata);
public sealed record CreateTaxonomyNodeCommand(
Guid? ParentId,
QuestionSource? ParentSource,
TaxonomyNodeType NodeType,
string Code,
string Name,
int SortOrder,
JsonElement Metadata);
public interface ITaxonomyService
{
Task<IReadOnlyCollection<TaxonomyNodeItem>> ListAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<TaxonomyNodeItem> CreateAsync(
Guid tenantId,
CreateTaxonomyNodeCommand command,
CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Tiku.Application.Catalog;
using Tiku.Domain.Content;
using Tiku.Application.QuestionBanks;
namespace Tiku.Application.Content;
@@ -79,7 +80,7 @@ public sealed record ReplaceCollectionItemsCommand(
IReadOnlyCollection<CollectionQuestionCommand> Questions);
public sealed record CollectionQuestionCommand(
Guid QuestionId,
QuestionLocator Locator,
string? SectionKey,
int? Order,
decimal? Score,
@@ -173,6 +174,7 @@ public sealed record QuestionCollectionItemManagementItem(
Guid Id,
Guid CollectionId,
Guid QuestionId,
QuestionLocator Locator,
string? SectionKey,
int Order,
decimal? Score,

View File

@@ -8,7 +8,9 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ICurrentTenant, CurrentTenant>();
services.AddScoped<TenantContext>();
services.AddScoped<ITenantContext>(provider => provider.GetRequiredService<TenantContext>());
services.AddScoped<ITenantContextInitializer>(provider => provider.GetRequiredService<TenantContext>());
return services;
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Application.QuestionBanks;
namespace Tiku.Application.Learning;
@@ -9,13 +10,12 @@ public sealed record LearningActor(Guid TenantId, Guid UserId);
public sealed record LearningList<TItem>(IReadOnlyCollection<TItem> Items);
public sealed record SubmitAnswerCommand(
Guid QuestionId,
Guid? PracticeSessionId,
Guid SessionQuestionId,
IReadOnlyCollection<string>? SelectedOptions,
string? AnswerText,
bool? SelfJudgedCorrect);
public sealed record QuestionActionCommand(Guid QuestionId, bool? Favorite);
public sealed record QuestionActionCommand(QuestionLocator Locator, bool? Favorite);
public sealed record WordProgressCommand(
Guid WordId,
@@ -55,7 +55,11 @@ public sealed record LearningLeaderboardResult(
LearningLeaderboardItem? CurrentUser,
DateTimeOffset GeneratedAt);
public sealed record WrongQuestionReviewPlanItem(Guid QuestionId, int WrongCount, DateTimeOffset LastWrongAt);
public sealed record WrongQuestionReviewPlanItem(
Guid QuestionReferenceId,
QuestionLocator Locator,
int WrongCount,
DateTimeOffset LastWrongAt);
public sealed record WrongQuestionReviewPlan(
IReadOnlyCollection<WrongQuestionReviewPlanItem> Items,
@@ -106,21 +110,21 @@ public sealed record PracticeSessionFilter(
public sealed record AnswerRecordItem(
Guid Id,
Guid QuestionId,
Guid? QuestionVersionId,
Guid? PracticeSessionId,
Guid SessionQuestionId,
Guid PracticeSessionId,
JsonElement SelectedOptions,
string? AnswerText,
bool? IsCorrect,
DateTimeOffset AnsweredAt);
public sealed record FavoriteQuestionItem(
Guid QuestionId,
string Source,
Guid QuestionReferenceId,
QuestionLocator Locator,
DateTimeOffset CreatedAt);
public sealed record WrongQuestionItem(
Guid QuestionId,
Guid QuestionReferenceId,
QuestionLocator Locator,
int WrongCount,
DateTimeOffset LastWrongAt,
DateTimeOffset? ResolvedAt);
@@ -154,7 +158,6 @@ public sealed record PracticeSessionItem(
Guid? CollectionId,
Guid? EntryId,
Guid? ContentNodeId,
JsonElement QuestionIds,
int QuestionCount,
int? DurationMinutes,
decimal? TotalScore,
@@ -171,10 +174,13 @@ public sealed record PracticeSessionItem(
public sealed record PracticeSessionDetailItem(
PracticeSessionItem Session,
IReadOnlyCollection<PracticeSessionQuestionItem> Questions,
IReadOnlyDictionary<Guid, AnswerRecordItem> AnswersByQuestion);
IReadOnlyDictionary<Guid, AnswerRecordItem> AnswersBySessionQuestion);
public sealed record PracticeSessionQuestionItem(
Guid Id,
Guid SessionQuestionId,
Guid QuestionReferenceId,
QuestionLocator Locator,
Guid QuestionId,
string Type,
string? TypeLabel,
int? Difficulty,

View File

@@ -0,0 +1,27 @@
using Tiku.Domain.Content;
namespace Tiku.Application.QuestionBanks;
public interface IPublicQuestionAccessPolicy
{
Task EnsureCanStartAsync(Guid tenantId, CancellationToken cancellationToken = default);
}
public interface IQuestionReferenceService
{
Task<TenantQuestionReference> ResolveAsync(
Guid tenantId,
Guid? userId,
QuestionLocator locator,
CancellationToken cancellationToken = default);
}
public sealed class PublicQuestionAccessDeniedException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
public sealed class QuestionLocatorException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Content;
namespace Tiku.Application.QuestionBanks;
@@ -17,13 +18,14 @@ public sealed record QuestionBankFilter(
IReadOnlyCollection<Guid>? QuestionIds = null,
string? Type = null,
string? Keyword = null,
int? Limit = null);
int? Limit = null,
QuestionSource? Source = null);
public sealed record QuestionBankCatalogItem(
Guid Id,
Guid? RegionId,
string Name,
QuestionBankScope SourceScope,
QuestionSource Source,
QuestionBankStatus Status,
JsonElement Metadata);
@@ -58,7 +60,10 @@ public sealed record QuestionCatalogItem(
string? Explanation,
JsonElement SubQuestions,
string? CodeLang,
string? CodeTemplate);
string? CodeTemplate,
QuestionLocator Locator);
public sealed record QuestionLocator(QuestionSource Source, Guid QuestionId);
public sealed record QuestionVersionCatalogItem(
Guid Id,

View File

@@ -1,16 +0,0 @@
using System.Security.Claims;
namespace Tiku.Application.Security;
public sealed class CurrentTenant : ICurrentTenant
{
public Guid? TenantId { get; private set; }
public string? Role { get; private set; }
public bool IsResolved => TenantId.HasValue;
public void Load(ClaimsPrincipal principal)
{
TenantId = principal.FindGuid(TikuClaimTypes.TenantId);
Role = principal.FindValue(TikuClaimTypes.TenantRole);
}
}

View File

@@ -8,6 +8,7 @@ public sealed class CurrentUser : ICurrentUser
public Guid? SessionId { get; private set; }
public string? Phone { get; private set; }
public string? Email { get; private set; }
public string? TenantRole { get; private set; }
public bool IsAuthenticated { get; private set; }
public void Load(ClaimsPrincipal principal)
@@ -17,5 +18,6 @@ public sealed class CurrentUser : ICurrentUser
SessionId = principal.FindGuid(TikuClaimTypes.SessionId);
Phone = principal.FindValue(TikuClaimTypes.Phone);
Email = principal.FindValue(TikuClaimTypes.Email);
TenantRole = principal.FindValue(TikuClaimTypes.TenantRole);
}
}

View File

@@ -1,11 +0,0 @@
using System.Security.Claims;
namespace Tiku.Application.Security;
public interface ICurrentTenant
{
Guid? TenantId { get; }
string? Role { get; }
bool IsResolved { get; }
void Load(ClaimsPrincipal principal);
}

View File

@@ -8,6 +8,7 @@ public interface ICurrentUser
Guid? SessionId { get; }
string? Phone { get; }
string? Email { get; }
string? TenantRole { get; }
bool IsAuthenticated { get; }
void Load(ClaimsPrincipal principal);
}

View File

@@ -0,0 +1,87 @@
namespace Tiku.Application.Security;
public enum TenantResolutionSource
{
None,
Host,
TenantCode,
Jwt,
RefreshToken,
System
}
public interface ITenantContext
{
Guid? TenantId { get; }
string? TenantCode { get; }
TenantResolutionSource ResolutionSource { get; }
bool IsResolved { get; }
bool IsSystem { get; }
}
public interface ITenantContextInitializer
{
void Initialize(Guid tenantId, string? tenantCode, TenantResolutionSource source);
void InitializeSystem(Guid? targetTenantId, string reason);
}
public sealed class TenantContext : ITenantContext, ITenantContextInitializer
{
public Guid? TenantId { get; private set; }
public string? TenantCode { get; private set; }
public TenantResolutionSource ResolutionSource { get; private set; }
public bool IsResolved => TenantId.HasValue;
public bool IsSystem { get; private set; }
internal string? SystemReason { get; private set; }
public void Initialize(Guid tenantId, string? tenantCode, TenantResolutionSource source)
{
if (tenantId == Guid.Empty)
{
throw new ArgumentException("Tenant ID cannot be empty.", nameof(tenantId));
}
if (IsSystem)
{
throw new InvalidOperationException("A system tenant context cannot be replaced.");
}
if (TenantId.HasValue && TenantId.Value != tenantId)
{
throw new TenantContextConflictException(TenantId.Value, tenantId);
}
var wasResolved = TenantId.HasValue;
TenantId = tenantId;
TenantCode = string.IsNullOrWhiteSpace(tenantCode) ? TenantCode : tenantCode.Trim();
if (!wasResolved)
{
ResolutionSource = source;
}
}
public void InitializeSystem(Guid? targetTenantId, string reason)
{
if (IsResolved || IsSystem)
{
throw new InvalidOperationException("Tenant context has already been initialized.");
}
if (string.IsNullOrWhiteSpace(reason))
{
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
}
TenantId = targetTenantId;
ResolutionSource = TenantResolutionSource.System;
IsSystem = true;
SystemReason = reason.Trim();
}
}
public sealed class TenantContextConflictException(Guid expectedTenantId, Guid actualTenantId)
: Exception($"Resolved tenant '{expectedTenantId}' conflicts with authenticated tenant '{actualTenantId}'.")
{
public Guid ExpectedTenantId { get; } = expectedTenantId;
public Guid ActualTenantId { get; } = actualTenantId;
}

View File

@@ -0,0 +1,16 @@
namespace Tiku.Application.Security;
public interface ITenantExecutionScope
{
Task ExecuteAsync(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task> operation,
CancellationToken cancellationToken = default);
Task<TResult> ExecuteAsync<TResult>(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,22 @@
using Tiku.Domain.Tenancy;
namespace Tiku.Application.Tenancy;
public sealed record TenantDirectoryEntry(
Guid TenantId,
string TenantCode,
string Name,
TenantStatus Status,
TenantMode Mode,
string? Host);
public interface ITenantDirectory
{
Task<TenantDirectoryEntry?> FindByHostAsync(
string host,
CancellationToken cancellationToken = default);
Task<TenantDirectoryEntry?> FindByCodeAsync(
string tenantCode,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,39 @@
namespace Tiku.Application.Tenancy;
public sealed class DomainLifecycleOptions
{
public bool Enabled { get; set; } = true;
public int PollSeconds { get; set; } = 60;
public int BatchSize { get; set; } = 50;
public string DnsJsonEndpoint { get; set; } = "https://cloudflare-dns.com/dns-query";
public string VerificationRecordPrefix { get; set; } = "_tiku-verification";
public string[] AllowedCnameTargets { get; set; } = [];
public string? GatewayBaseUrl { get; set; }
public string? GatewayApiKey { get; set; }
}
public sealed record DomainOwnershipResult(bool Verified, bool Configured, string? FailureReason);
public sealed record DomainGatewayResult(bool TlsReady, bool Configured, string? FailureReason);
public interface IDomainOwnershipVerifier
{
Task<DomainOwnershipResult> VerifyAsync(
string host,
string verificationToken,
CancellationToken cancellationToken = default);
}
public interface IDomainGatewayProvisioner
{
Task<DomainGatewayResult> EnsureTlsAsync(string host, CancellationToken cancellationToken = default);
}
public interface ITenantDomainLifecycleService
{
Task<int> ProcessPendingAsync(CancellationToken cancellationToken = default);
}
public interface ITenantRuntimeCacheInvalidator
{
void Invalidate(Guid tenantId);
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
namespace Tiku.Application.Tenancy;
public sealed record TenantFrontendConfigDraft(
JsonElement Branding,
JsonElement Theme,
JsonElement Features,
JsonElement Navigation,
JsonElement HomeModules);
public sealed record TenantFrontendConfigItem(
int SchemaVersion,
int ConfigVersion,
TenantFrontendConfigDraft Published,
TenantFrontendConfigDraft Draft,
DateTimeOffset? PublishedAt);
public sealed record TenantRuntimeBootstrap(
int SchemaVersion,
int ConfigVersion,
string TenantCode,
string TenantName,
JsonElement Branding,
JsonElement Theme,
JsonElement Features,
JsonElement Navigation,
JsonElement HomeModules);
public interface ITenantFrontendConfigService
{
Task<TenantFrontendConfigItem> GetAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<TenantFrontendConfigItem> SaveDraftAsync(
Guid tenantId,
TenantFrontendConfigDraft draft,
CancellationToken cancellationToken = default);
Task<TenantFrontendConfigItem> PublishAsync(
Guid tenantId,
int expectedVersion,
CancellationToken cancellationToken = default);
Task<TenantRuntimeBootstrap> GetRuntimeAsync(Guid tenantId, CancellationToken cancellationToken = default);
}
public sealed class TenantFrontendConfigException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -460,6 +460,10 @@ public sealed record TenantDomainItem(
bool IsPrimary,
string? VerificationToken,
DateTimeOffset? VerifiedAt,
DateTimeOffset? LastCheckedAt,
DateTimeOffset? DnsVerifiedAt,
DateTimeOffset? TlsReadyAt,
string? LastFailureReason,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);