feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user