forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -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);
|
||||
|
||||
36
Tiku.Application/Catalog/TaxonomyModels.cs
Normal file
36
Tiku.Application/Catalog/TaxonomyModels.cs
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
Tiku.Application/QuestionBanks/IQuestionReferenceService.cs
Normal file
27
Tiku.Application/QuestionBanks/IQuestionReferenceService.cs
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
87
Tiku.Application/Security/ITenantContext.cs
Normal file
87
Tiku.Application/Security/ITenantContext.cs
Normal 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;
|
||||
}
|
||||
16
Tiku.Application/Security/ITenantExecutionScope.cs
Normal file
16
Tiku.Application/Security/ITenantExecutionScope.cs
Normal 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);
|
||||
}
|
||||
22
Tiku.Application/Tenancy/TenantDirectoryModels.cs
Normal file
22
Tiku.Application/Tenancy/TenantDirectoryModels.cs
Normal 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);
|
||||
}
|
||||
39
Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs
Normal file
39
Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs
Normal 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);
|
||||
}
|
||||
47
Tiku.Application/Tenancy/TenantFrontendConfigModels.cs
Normal file
47
Tiku.Application/Tenancy/TenantFrontendConfigModels.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user