feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

@@ -4,17 +4,20 @@ using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Content;
public sealed class ContentManagementService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IContentManagementService
IQuestionReferenceService questionReferenceService,
ICurrentAccessContext currentAccessContext) : IContentManagementService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -24,9 +27,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ContentEntries
.AsNoTracking()
.Where(entry => entry.TenantId == actor.TenantId);
.Where(entry => entry.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
entry => entry.CreatedBy == actor.UserId,
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -67,6 +76,7 @@ public sealed class ContentManagementService(
UpsertContentEntryCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
@@ -81,6 +91,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = entry is null;
if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
entry ??= new ContentEntry
{
Id = command.Id ?? Guid.NewGuid(),
@@ -117,14 +142,21 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
if (!filter.EntryId.HasValue)
{
throw new ContentManagementException("entryId is required.", "entry_id_required");
}
await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ContentNodes
.AsNoTracking()
.Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value);
.Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value)
.ApplyDataScope(
scope,
node => node.CreatedBy == actor.UserId,
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -178,9 +210,11 @@ public sealed class ContentManagementService(
UpsertContentNodeCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken);
var nodeKey = Normalize(command.NodeKey) ??
Normalize(command.Id?.ToString("N")) ??
@@ -193,6 +227,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = node is null;
if (command.Id.HasValue && (node is null || node.Id != command.Id.Value))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
node ??= new ContentNode
{
Id = command.Id ?? Guid.NewGuid(),
@@ -246,9 +295,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.QuestionCollections
.AsNoTracking()
.Where(collection => collection.TenantId == actor.TenantId);
.Where(collection => collection.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
collection => collection.CreatedBy == actor.UserId,
collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -296,10 +351,11 @@ public sealed class ContentManagementService(
UpsertQuestionCollectionCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
@@ -312,6 +368,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = collection is null;
if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
collection ??= new QuestionCollection
{
Id = command.Id ?? Guid.NewGuid(),
@@ -352,9 +423,15 @@ public sealed class ContentManagementService(
ReplaceCollectionItemsCommand command,
CancellationToken cancellationToken = default)
{
var collection = await dbContext.QuestionCollections.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.CollectionId,
cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var collection = await dbContext.QuestionCollections
.Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId)
.ApplyDataScope(
scope,
item => item.CreatedBy == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
.SingleOrDefaultAsync(cancellationToken);
if (collection is null)
{
@@ -409,9 +486,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.PracticeBlueprints
.AsNoTracking()
.Where(blueprint => blueprint.TenantId == actor.TenantId);
.Where(blueprint => blueprint.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
blueprint => blueprint.CreatedBy == actor.UserId,
blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -464,10 +547,11 @@ public sealed class ContentManagementService(
UpsertPracticeBlueprintCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken);
var blueprint = await ResolveEntityByIdOrLegacyAsync(
@@ -478,6 +562,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = blueprint is null;
if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
blueprint ??= new PracticeBlueprint
{
Id = command.Id ?? Guid.NewGuid(),
@@ -584,11 +683,74 @@ public sealed class ContentManagementService(
await AssertReferenceAsync<ContentEntry>(tenantId, entryId, "entry_not_found", cancellationToken);
}
private async Task AssertEntryAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? entryId,
CancellationToken cancellationToken)
{
if (!entryId.HasValue)
{
return;
}
var regionIds = scope.RegionIds.ToArray();
var exists = await dbContext.ContentEntries
.Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value)
.ApplyDataScope(
scope,
entry => entry.CreatedBy == actor.UserId,
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists)
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
}
private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", cancellationToken);
}
private async Task AssertNodeAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? nodeId,
CancellationToken cancellationToken)
{
if (!nodeId.HasValue)
{
return;
}
var regionIds = scope.RegionIds.ToArray();
var exists = await dbContext.ContentNodes
.Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value)
.ApplyDataScope(
scope,
node => node.CreatedBy == actor.UserId,
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists)
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
ContentManagementActor actor,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
{
throw new ContentManagementException("Content resource was not found.", "content_not_found");
}
return access.DataScope;
}
private async Task AssertReferenceAsync<TEntity>(
Guid tenantId,
Guid? id,

View File

@@ -5,6 +5,7 @@ using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
@@ -12,12 +13,14 @@ using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Content;
public sealed class DirectContentService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IDirectContentService
IQuestionReferenceService questionReferenceService,
ICurrentAccessContext currentAccessContext) : IDirectContentService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -120,7 +123,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.VocabularyUnits.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.VocabularyUnits.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -159,6 +166,7 @@ public sealed class DirectContentService(
VocabularyUnitCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
@@ -166,6 +174,7 @@ public sealed class DirectContentService(
var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found");
item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.EntryId = command.EntryId;
@@ -272,7 +281,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.HandbookSubjects.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.HandbookSubjects.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -321,6 +334,7 @@ public sealed class DirectContentService(
HandbookSubjectCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
@@ -330,6 +344,7 @@ public sealed class DirectContentService(
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found");
item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -513,7 +528,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Schools.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.Schools.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -536,10 +555,12 @@ public sealed class DirectContentService(
SchoolCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found");
item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.LegacyId = Normalize(command.LegacyId);
@@ -560,7 +581,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Majors.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.Majors.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -594,11 +619,13 @@ public sealed class DirectContentService(
MajorCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found");
item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -622,7 +649,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineFields.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineFields.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
@@ -646,6 +677,7 @@ public sealed class DirectContentService(
ScorelineFieldCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim()))
@@ -656,6 +688,7 @@ public sealed class DirectContentService(
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found");
item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.LegacyId = Normalize(command.LegacyId);
@@ -685,7 +718,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineRecords.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -727,6 +764,7 @@ public sealed class DirectContentService(
ScorelineRecordCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
if (command.Year is < 1900 or > 3000)
{
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
@@ -737,6 +775,7 @@ public sealed class DirectContentService(
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found");
item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -760,8 +799,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -1720,6 +1762,38 @@ public sealed class DirectContentService(
item.IssuesCount);
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
DirectContentActor actor,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantContentManage))
{
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
}
return access.DataScope;
}
private static void EnsureRegionWriteAllowed(
CurrentDataScope scope,
DirectContentActor actor,
Guid? currentRegionId,
Guid? targetRegionId,
bool isNew,
string notFoundCode)
{
var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId);
var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId);
if (!canAccessCurrent || !canAccessTarget)
{
throw new ContentManagementException("Content resource was not found.", notFoundCode);
}
}
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,