feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestionBankQueryService
|
||||
public sealed class QuestionBankQueryService(
|
||||
TikuDbContext dbContext,
|
||||
IPublicQuestionAccessPolicy accessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : IQuestionBankQueryService
|
||||
{
|
||||
private const int DefaultQuestionLimit = 200;
|
||||
private const int MaxQuestionLimit = 500;
|
||||
@@ -19,47 +25,84 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
QuestionBankFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.QuestionBanks
|
||||
var limit = ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit);
|
||||
var tenantItems = filter.Source == QuestionSource.Platform
|
||||
? []
|
||||
: await dbContext.QuestionBanks
|
||||
.AsNoTracking()
|
||||
.Where(bank =>
|
||||
bank.TenantId == filter.TenantId &&
|
||||
bank.Status == QuestionBankStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(bank => bank.RegionId == filter.RegionId.Value || bank.RegionId == null);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
var items = await query
|
||||
bank.Status == QuestionBankStatus.Active &&
|
||||
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
|
||||
.OrderBy(bank => bank.Name)
|
||||
.ThenBy(bank => bank.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit))
|
||||
.Take(limit)
|
||||
.Select(bank => new QuestionBankCatalogItem(
|
||||
bank.Id,
|
||||
bank.RegionId,
|
||||
bank.Name,
|
||||
bank.SourceScope,
|
||||
QuestionSource.Tenant,
|
||||
bank.Status,
|
||||
bank.Metadata))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionBankCatalogItem>(items);
|
||||
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
||||
? []
|
||||
: await tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform question banks for an entitled tenant",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await systemDbContext.QuestionBanks.AsNoTracking()
|
||||
.Join(
|
||||
systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
|
||||
bank => bank.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(bank, tenant) => bank)
|
||||
.Where(bank =>
|
||||
bank.Status == QuestionBankStatus.Active &&
|
||||
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
|
||||
.OrderBy(bank => bank.Name)
|
||||
.Take(limit)
|
||||
.Select(bank => new QuestionBankCatalogItem(
|
||||
bank.Id,
|
||||
bank.RegionId,
|
||||
bank.Name,
|
||||
QuestionSource.Platform,
|
||||
bank.Status,
|
||||
bank.Metadata))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionBankCatalogItem>(tenantItems
|
||||
.Concat(platformItems)
|
||||
.OrderBy(item => item.Name)
|
||||
.Take(limit)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionCatalogItem>> GetQuestionsAsync(
|
||||
QuestionBankFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||
.OrderByDescending(question => question.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit));
|
||||
|
||||
var items = await ProjectQuestions(questions)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionCatalogItem>(items);
|
||||
var limit = ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit);
|
||||
var tenantItems = filter.Source == QuestionSource.Platform
|
||||
? []
|
||||
: await ProjectQuestions(
|
||||
dbContext,
|
||||
ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||
.OrderByDescending(question => question.CreatedAt)
|
||||
.Take(limit),
|
||||
QuestionSource.Tenant)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
||||
? []
|
||||
: await GetPlatformQuestionsAsync(filter, limit, cancellationToken);
|
||||
return new CatalogList<QuestionCatalogItem>(tenantItems.Concat(platformItems).Take(limit).ToArray());
|
||||
}
|
||||
|
||||
public async Task<QuestionCatalogItem> GetQuestionAsync(
|
||||
@@ -71,13 +114,9 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
throw new QuestionBankRequiredFieldException("questionId is required.");
|
||||
}
|
||||
|
||||
var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||
.Where(question => question.Id == filter.QuestionId.Value);
|
||||
|
||||
var question = await ProjectQuestions(questions)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return question ?? throw new QuestionBankNotFoundException("Question was not found.");
|
||||
var result = await GetQuestionsAsync(filter with { Limit = 2 }, cancellationToken);
|
||||
return result.Items.SingleOrDefault()
|
||||
?? throw new QuestionBankNotFoundException("Question was not found.");
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
|
||||
@@ -89,6 +128,13 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
throw new QuestionBankRequiredFieldException("questionId is required.");
|
||||
}
|
||||
|
||||
if (filter.Source == QuestionSource.Platform)
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(filter.TenantId, cancellationToken);
|
||||
var platformItems = await GetPlatformVersionsAsync(filter.QuestionId.Value, filter.TenantId, cancellationToken);
|
||||
return new CatalogList<QuestionVersionCatalogItem>(platformItems);
|
||||
}
|
||||
|
||||
var questionExists = await dbContext.Questions
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
@@ -221,14 +267,17 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
return query.Where(entity => EF.Property<string>(entity, nameof(QuestionBank.Name)).Contains(trimmed));
|
||||
}
|
||||
|
||||
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
|
||||
private static IQueryable<QuestionCatalogItem> ProjectQuestions(
|
||||
TikuDbContext context,
|
||||
IQueryable<Question> questions,
|
||||
QuestionSource source)
|
||||
{
|
||||
var emptyOptions = JsonDefaults.Array();
|
||||
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||
var emptySubQuestions = JsonDefaults.Array();
|
||||
return
|
||||
from question in questions
|
||||
join version in dbContext.QuestionVersions.AsNoTracking()
|
||||
join version in context.QuestionVersions.AsNoTracking()
|
||||
on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId }
|
||||
equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id }
|
||||
into versions
|
||||
@@ -264,7 +313,113 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? emptySubQuestions : version.SubQuestions,
|
||||
version == null ? null : version.CodeLang,
|
||||
version == null ? null : version.CodeTemplate);
|
||||
version == null ? null : version.CodeTemplate,
|
||||
new QuestionLocator(source, question.Id));
|
||||
}
|
||||
|
||||
private async Task<bool> CanAccessPlatformAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (PublicQuestionAccessDeniedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<QuestionCatalogItem[]> GetPlatformQuestionsAsync(
|
||||
QuestionBankFilter filter,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform questions for an entitled tenant",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
var platformTenantId = await systemDbContext.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||
.Select(tenant => tenant.Id)
|
||||
.SingleAsync(token);
|
||||
var query = systemDbContext.Questions.AsNoTracking().Where(question =>
|
||||
question.TenantId == platformTenantId &&
|
||||
question.Status == QuestionStatus.Published &&
|
||||
(!filter.QuestionId.HasValue || question.Id == filter.QuestionId.Value) &&
|
||||
(!filter.QuestionBankId.HasValue || question.QuestionBankId == filter.QuestionBankId.Value) &&
|
||||
(!filter.SubjectId.HasValue || question.SubjectId == filter.SubjectId.Value) &&
|
||||
(!filter.CategoryId.HasValue || question.CategoryId == filter.CategoryId.Value) &&
|
||||
(!filter.NodeId.HasValue || question.NodeId == filter.NodeId.Value) &&
|
||||
(!filter.EntryId.HasValue || question.EntryId == filter.EntryId.Value) &&
|
||||
(!filter.ContentNodeId.HasValue || question.ContentNodeId == filter.ContentNodeId.Value) &&
|
||||
(filter.QuestionIds == null || filter.QuestionIds.Count == 0 || filter.QuestionIds.Contains(question.Id)) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Type) || question.Type == filter.Type.Trim()));
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(question =>
|
||||
question.Type.Contains(keyword) ||
|
||||
(question.TypeLabel != null && question.TypeLabel.Contains(keyword)));
|
||||
}
|
||||
|
||||
return await ProjectQuestions(
|
||||
systemDbContext,
|
||||
query.OrderByDescending(question => question.CreatedAt).Take(limit),
|
||||
QuestionSource.Platform)
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<QuestionVersionCatalogItem[]> GetPlatformVersionsAsync(
|
||||
Guid questionId,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read platform question versions for an entitled tenant",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
var platformQuestion = await systemDbContext.Questions.AsNoTracking()
|
||||
.Where(question => question.Id == questionId && question.Status == QuestionStatus.Published)
|
||||
.Join(
|
||||
systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
|
||||
question => question.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(question, tenant) => new { question.TenantId, question.Id })
|
||||
.SingleOrDefaultAsync(token);
|
||||
if (platformQuestion is null)
|
||||
{
|
||||
throw new QuestionBankNotFoundException("Question was not found.");
|
||||
}
|
||||
|
||||
return await systemDbContext.QuestionVersions.AsNoTracking()
|
||||
.Where(version =>
|
||||
version.TenantId == platformQuestion.TenantId &&
|
||||
version.QuestionId == platformQuestion.Id)
|
||||
.OrderByDescending(version => version.VersionNo)
|
||||
.Select(version => new QuestionVersionCatalogItem(
|
||||
version.Id,
|
||||
version.QuestionId,
|
||||
version.VersionNo,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation,
|
||||
version.SubQuestions,
|
||||
version.CodeLang,
|
||||
version.CodeTemplate,
|
||||
version.CreatedAt))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit, int defaultLimit, int maxLimit)
|
||||
|
||||
Reference in New Issue
Block a user