feat(security): complete capability messaging workflows
This commit is contained in:
@@ -128,6 +128,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddSingleton<IBackgroundJobDispatcher, NullBackgroundJobDispatcher>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||
@@ -209,6 +210,12 @@ public static class DependencyInjection
|
||||
consumer.UseMessageRetry(retry => retry.Intervals(
|
||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
||||
});
|
||||
registration.AddConsumer<BackgroundJobRequestedConsumer>(consumer =>
|
||||
{
|
||||
consumer.ConcurrentMessageLimit = 1;
|
||||
consumer.UseMessageRetry(retry => retry.Intervals(
|
||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
||||
});
|
||||
registration.AddConfigureEndpointsCallback((context, _, endpoint) =>
|
||||
{
|
||||
endpoint.PrefetchCount = 1;
|
||||
@@ -228,6 +235,7 @@ public static class DependencyInjection
|
||||
});
|
||||
});
|
||||
services.AddScoped<ISecurityEventPublisher, MassTransitSecurityEventPublisher>();
|
||||
services.AddScoped<IBackgroundJobDispatcher, MassTransitBackgroundJobDispatcher>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
internal sealed class BackgroundJobService(
|
||||
TikuDbContext dbContext,
|
||||
IServiceProvider serviceProvider,
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackgroundJobService
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator,
|
||||
IBackgroundJobDispatcher backgroundJobDispatcher) : IBackgroundJobService
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -43,6 +43,15 @@ internal sealed class BackgroundJobService(
|
||||
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
||||
};
|
||||
dbContext.BackgroundJobs.Add(job);
|
||||
if (job.RunAfter is null && backgroundJobDispatcher.IsEnabled)
|
||||
{
|
||||
await backgroundJobDispatcher.DispatchAsync(
|
||||
job.Id,
|
||||
job.TenantId,
|
||||
job.JobType,
|
||||
job.Id.ToString("N"),
|
||||
cancellationToken);
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(job);
|
||||
}
|
||||
@@ -50,12 +59,14 @@ internal sealed class BackgroundJobService(
|
||||
public async Task<int> ProcessPendingAsync(
|
||||
string workerId,
|
||||
int batchSize,
|
||||
bool includeImmediateJobs = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var jobs = await dbContext.BackgroundJobs
|
||||
.Where(job =>
|
||||
job.Status == BackgroundJobStatus.Pending &&
|
||||
(includeImmediateJobs || job.RunAfter != null) &&
|
||||
(job.RunAfter == null || job.RunAfter <= now))
|
||||
.OrderBy(job => job.CreatedAt)
|
||||
.Take(Math.Clamp(batchSize, 1, 100))
|
||||
@@ -65,59 +76,102 @@ internal sealed class BackgroundJobService(
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
job.TenantId,
|
||||
ResolveCapabilityModule(job.JobType),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
{
|
||||
job.Status = BackgroundJobStatus.Failed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = "Tenant capability was revoked before job execution.";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
processed++;
|
||||
continue;
|
||||
}
|
||||
job.Status = BackgroundJobStatus.Processing;
|
||||
job.LockedBy = workerId;
|
||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||
job.StartedAt = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
job.TenantId, SystemScopeCallerType.Worker, workerId,
|
||||
$"Background job {job.JobType}", job.Id.ToString("N")),
|
||||
(provider, token) => ProcessCoreAsync(provider, job, token),
|
||||
cancellationToken);
|
||||
job.Status = BackgroundJobStatus.Succeeded;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = null;
|
||||
job.Result = result;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
job.RetryCount++;
|
||||
job.LastError = exception.Message;
|
||||
job.Status = job.RetryCount > job.MaxRetries
|
||||
? BackgroundJobStatus.Failed
|
||||
: BackgroundJobStatus.Pending;
|
||||
job.RunAfter = DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount));
|
||||
}
|
||||
finally
|
||||
{
|
||||
job.LockedBy = null;
|
||||
job.LockExpiresAt = null;
|
||||
processed++;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
if (await ProcessJobAsync(job, workerId, cancellationToken)) processed++;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessRequestedAsync(
|
||||
Guid jobId,
|
||||
Guid tenantId,
|
||||
string jobType,
|
||||
string workerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedJobType = NormalizeJobType(jobType);
|
||||
var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync(
|
||||
item => item.Id == jobId && item.TenantId == tenantId,
|
||||
cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new InvalidOperationException("The requested background job does not exist in the target tenant.");
|
||||
}
|
||||
if (!string.Equals(job.JobType, normalizedJobType, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The requested background job type does not match the persisted job.");
|
||||
}
|
||||
if (job.Status != BackgroundJobStatus.Pending || job.RunAfter is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await ProcessJobAsync(job, workerId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessJobAsync(
|
||||
BackgroundJob job,
|
||||
string workerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (job.Status != BackgroundJobStatus.Pending)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
job.TenantId,
|
||||
ResolveCapabilityModule(job.JobType),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
{
|
||||
job.Status = BackgroundJobStatus.Failed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = "Tenant capability was revoked before job execution.";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
job.Status = BackgroundJobStatus.Processing;
|
||||
job.LockedBy = workerId;
|
||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||
job.StartedAt = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
job.TenantId, SystemScopeCallerType.Worker, workerId,
|
||||
$"Background job {job.JobType}", job.Id.ToString("N")),
|
||||
(provider, token) => ProcessCoreAsync(provider, job, token),
|
||||
cancellationToken);
|
||||
job.Status = BackgroundJobStatus.Succeeded;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = null;
|
||||
job.Result = result;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
job.RetryCount++;
|
||||
job.LastError = exception.Message;
|
||||
job.Status = job.RetryCount > job.MaxRetries
|
||||
? BackgroundJobStatus.Failed
|
||||
: BackgroundJobStatus.Pending;
|
||||
job.RunAfter = job.Status == BackgroundJobStatus.Pending
|
||||
? DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount))
|
||||
: null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
job.LockedBy = null;
|
||||
job.LockExpiresAt = null;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
||||
Guid tenantId,
|
||||
string? jobType = null,
|
||||
@@ -145,14 +199,15 @@ internal sealed class BackgroundJobService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var scopedDbContext = scopedProvider.GetRequiredService<TikuDbContext>();
|
||||
return job.JobType switch
|
||||
{
|
||||
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
|
||||
"content_export" => await ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
|
||||
"content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken),
|
||||
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
|
||||
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(job, cancellationToken),
|
||||
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
|
||||
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(cancellationToken),
|
||||
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedDbContext, job, cancellationToken),
|
||||
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
|
||||
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken),
|
||||
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
|
||||
};
|
||||
}
|
||||
@@ -199,12 +254,13 @@ internal sealed class BackgroundJobService(
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessContentExportAsync(
|
||||
TikuDbContext scopedDbContext,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exportType = GetJsonString(job.Payload, "exportType") ?? "summary";
|
||||
var assetKey = $"background-jobs/{job.Id:N}/content-export.json";
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == job.TenantId && item.AssetKey == assetKey,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
@@ -219,12 +275,12 @@ internal sealed class BackgroundJobService(
|
||||
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
|
||||
Source = "background_job"
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
scopedDbContext.ContentAssets.Add(asset);
|
||||
}
|
||||
|
||||
var questionBankCount = await dbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var questionCount = await dbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var studentCount = await dbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var questionBankCount = await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var questionCount = await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var studentCount = await scopedDbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
asset.Title = "Content export manifest";
|
||||
asset.Description = $"Generated content export manifest for {exportType}.";
|
||||
@@ -239,7 +295,7 @@ internal sealed class BackgroundJobService(
|
||||
studentCount,
|
||||
payload = job.Payload
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
job.OutputAssetId = asset.Id;
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
@@ -252,11 +308,12 @@ internal sealed class BackgroundJobService(
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessCommerceReconciliationAsync(
|
||||
TikuDbContext scopedDbContext,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var provider = NormalizeProvider(GetJsonString(job.Payload, "provider"));
|
||||
var hasProviderConfig = await dbContext.TenantExternalProviders.AnyAsync(
|
||||
var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync(
|
||||
item =>
|
||||
item.TenantId == job.TenantId &&
|
||||
item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment &&
|
||||
@@ -271,7 +328,7 @@ internal sealed class BackgroundJobService(
|
||||
var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date);
|
||||
var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined);
|
||||
var sourceHash = $"background-job:{job.Id:N}";
|
||||
var batch = await dbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
|
||||
var batch = await scopedDbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == job.TenantId &&
|
||||
item.Provider == provider &&
|
||||
@@ -296,8 +353,8 @@ internal sealed class BackgroundJobService(
|
||||
note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
|
||||
})
|
||||
};
|
||||
dbContext.CommerceReconciliationBatches.Add(batch);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
scopedDbContext.CommerceReconciliationBatches.Add(batch);
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
@@ -310,9 +367,11 @@ internal sealed class BackgroundJobService(
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessTenantDomainRecheckAsync(CancellationToken cancellationToken)
|
||||
private static async Task<JsonElement> ProcessTenantDomainRecheckAsync(
|
||||
IServiceProvider scopedProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lifecycleService = serviceProvider.GetRequiredService<ITenantDomainLifecycleService>();
|
||||
var lifecycleService = scopedProvider.GetRequiredService<ITenantDomainLifecycleService>();
|
||||
var processed = await lifecycleService.ProcessPendingAsync(cancellationToken);
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
@@ -321,18 +380,19 @@ internal sealed class BackgroundJobService(
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
|
||||
TikuDbContext scopedDbContext,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-7);
|
||||
var activeLearnerCount = await dbContext.PracticeSessions
|
||||
var activeLearnerCount = await scopedDbContext.PracticeSessions
|
||||
.Where(item => item.TenantId == job.TenantId && item.StartedAt >= since)
|
||||
.Select(item => item.UserId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
var paidOrderCount = await dbContext.Orders
|
||||
var paidOrderCount = await scopedDbContext.Orders
|
||||
.CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken);
|
||||
var revenueCents = await dbContext.Orders
|
||||
var revenueCents = await scopedDbContext.Orders
|
||||
.Where(item => item.TenantId == job.TenantId &&
|
||||
(item.Status == OrderStatus.Paid ||
|
||||
item.Status == OrderStatus.PartiallyRefunded ||
|
||||
|
||||
40
Tiku.Infrastructure/Messaging/BackgroundJobDispatcher.cs
Normal file
40
Tiku.Infrastructure/Messaging/BackgroundJobDispatcher.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using MassTransit;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
internal sealed class NullBackgroundJobDispatcher : IBackgroundJobDispatcher
|
||||
{
|
||||
public bool IsEnabled => false;
|
||||
|
||||
public Task DispatchAsync(
|
||||
Guid jobId,
|
||||
Guid tenantId,
|
||||
string jobType,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class MassTransitBackgroundJobDispatcher(IPublishEndpoint publishEndpoint) :
|
||||
IBackgroundJobDispatcher
|
||||
{
|
||||
public bool IsEnabled => true;
|
||||
|
||||
public Task DispatchAsync(
|
||||
Guid jobId,
|
||||
Guid tenantId,
|
||||
string jobType,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(
|
||||
new BackgroundJobRequestedV1(
|
||||
Guid.NewGuid(),
|
||||
jobId,
|
||||
tenantId,
|
||||
jobType,
|
||||
DateTimeOffset.UtcNow,
|
||||
correlationId),
|
||||
context => context.MessageId = jobId,
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MassTransit;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
[ConsumerAuthorizationMetadata(
|
||||
"tenant", "dynamic-job-module", CapabilityOperation.Write, "background_job.execute",
|
||||
RequiresSystemScope = true)]
|
||||
internal sealed class BackgroundJobRequestedConsumer(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ITenantContextInitializer tenantContextInitializer) : IConsumer<BackgroundJobRequestedV1>
|
||||
{
|
||||
public async Task Consume(ConsumeContext<BackgroundJobRequestedV1> context)
|
||||
{
|
||||
var message = context.Message;
|
||||
tenantContextInitializer.InitializeSystem(
|
||||
message.TenantId,
|
||||
$"RabbitMQ background job {message.JobType}");
|
||||
await backgroundJobService.ProcessRequestedAsync(
|
||||
message.JobId,
|
||||
message.TenantId,
|
||||
message.JobType,
|
||||
$"rabbitmq:{Environment.MachineName}",
|
||||
context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ public sealed class MessagingOptions
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool ConfigureConsumers { get; set; }
|
||||
public int OutboxBacklogAlertCount { get; set; } = 1000;
|
||||
public int OutboxOldestMessageAlertSeconds { get; set; } = 300;
|
||||
|
||||
public bool IsConfigured => Uri.TryCreate(Host, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme is "rabbitmq" or "amqp" or "amqps";
|
||||
|
||||
@@ -4,6 +4,8 @@ using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
[ConsumerAuthorizationMetadata(
|
||||
"system", "security-state", CapabilityOperation.Read, "security.invalidation.apply")]
|
||||
internal sealed class SecurityStateChangedConsumer(IRedisSecurityStore redisSecurityStore) :
|
||||
IConsumer<AuthorizationStateChangedV1>,
|
||||
IConsumer<TenantCapabilityChangedV1>,
|
||||
|
||||
18569
Tiku.Infrastructure/Persistence/Migrations/20260729025218_EnforceProductModuleCatalog.Designer.cs
generated
Normal file
18569
Tiku.Infrastructure/Persistence/Migrations/20260729025218_EnforceProductModuleCatalog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class EnforceProductModuleCatalog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO product_modules
|
||||
(id, code, name, status, sort_order, created_at, updated_at)
|
||||
VALUES
|
||||
('10000000-0000-0000-0000-000000000001', 'dashboard', 'Dashboard', 'active', 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000002', 'staff', 'Staff', 'active', 20, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000003', 'role', 'Roles', 'active', 30, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000004', 'student', 'Students', 'active', 40, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000005', 'content', 'Content', 'active', 50, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000006', 'settings', 'Settings', 'active', 60, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000007', 'provider', 'Providers', 'active', 70, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000008', 'commerce', 'Commerce', 'active', 80, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000009', 'crm', 'CRM', 'active', 90, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-00000000000a', 'commission', 'Commission', 'active', 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-00000000000b', 'job', 'Background Jobs', 'active', 110, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = 'active',
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO plan_module_entitlements (id, plan_code, module_code, enabled)
|
||||
SELECT
|
||||
md5('plan-module:' || plan.code || ':' || module.code)::uuid,
|
||||
plan.code,
|
||||
module.code,
|
||||
TRUE
|
||||
FROM platform_saas_plans AS plan
|
||||
CROSS JOIN product_modules AS module
|
||||
WHERE module.code IN
|
||||
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
|
||||
'provider', 'commerce', 'crm', 'commission', 'job')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM plan_module_entitlements AS existing
|
||||
WHERE existing.plan_code = plan.code)
|
||||
ON CONFLICT (plan_code, module_code) DO NOTHING;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM product_modules
|
||||
WHERE code IN
|
||||
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
|
||||
'provider', 'commerce', 'crm', 'commission', 'job');
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,124 @@ internal sealed class PlatformAdminService(
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(
|
||||
PlatformAdminActor actor,
|
||||
ReplacePlatformPlanModulesCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform plan module entitlements replace", async (provider, dbContext) =>
|
||||
{
|
||||
var planCode = NormalizeCode(command.PlanCode);
|
||||
var plan = await dbContext.PlatformSaasPlans.SingleOrDefaultAsync(
|
||||
item => item.Code == planCode,
|
||||
cancellationToken) ?? throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found");
|
||||
var moduleCodes = command.ModuleCodes
|
||||
.Select(NormalizeCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var invalidModules = moduleCodes.Where(module => !ProductModuleCatalog.Contains(module)).ToArray();
|
||||
if (invalidModules.Length > 0)
|
||||
{
|
||||
throw new PlatformAdminException("One or more product modules are invalid.", "product_module_invalid");
|
||||
}
|
||||
|
||||
var existing = await dbContext.PlanModuleEntitlements
|
||||
.Where(item => item.PlanCode == planCode)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var changedModules = existing.Select(item => item.ModuleCode)
|
||||
.Concat(moduleCodes)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
dbContext.PlanModuleEntitlements.RemoveRange(existing);
|
||||
dbContext.PlanModuleEntitlements.AddRange(moduleCodes.Select(moduleCode => new PlanModuleEntitlement
|
||||
{
|
||||
PlanCode = planCode,
|
||||
ModuleCode = moduleCode,
|
||||
Enabled = true
|
||||
}));
|
||||
AddAudit(dbContext, actor, "platform.plan.modules.replaced", plan.Id, new
|
||||
{
|
||||
planCode,
|
||||
previousModuleCodes = existing.Select(item => item.ModuleCode).Order(StringComparer.Ordinal),
|
||||
moduleCodes
|
||||
});
|
||||
|
||||
var tenantIds = await dbContext.TenantSubscriptions.AsNoTracking()
|
||||
.Where(item => item.PlanCode == planCode)
|
||||
.Select(item => item.TenantId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var publisher = provider.GetRequiredService<ISecurityEventPublisher>();
|
||||
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
foreach (var moduleCode in changedModules)
|
||||
{
|
||||
await publisher.CapabilityChangedAsync(
|
||||
tenantId, moduleCode, "plan_entitlements_changed", version,
|
||||
$"plan-modules-{plan.Id:N}", cancellationToken);
|
||||
}
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformPlanModuleEntitlements(planCode, moduleCodes);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformTenantModuleOverrideCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant module override upsert", async (provider, dbContext) =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
var moduleCode = NormalizeCode(command.ModuleCode);
|
||||
if (!ProductModuleCatalog.Contains(moduleCode))
|
||||
{
|
||||
throw new PlatformAdminException("Product module was not found.", "product_module_not_found");
|
||||
}
|
||||
var reason = command.Reason.Trim();
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new PlatformAdminException("Module override reason is required.", "module_override_reason_required");
|
||||
}
|
||||
|
||||
var item = await dbContext.TenantModuleOverrides.SingleOrDefaultAsync(
|
||||
value => value.TenantId == command.TenantId && value.ModuleCode == moduleCode,
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new TenantModuleOverride { TenantId = command.TenantId, ModuleCode = moduleCode };
|
||||
dbContext.TenantModuleOverrides.Add(item);
|
||||
}
|
||||
var previousMode = item.Mode;
|
||||
item.Mode = command.Mode;
|
||||
item.ExpiresAt = command.ExpiresAt;
|
||||
item.Reason = reason;
|
||||
AddAudit(dbContext, actor, "platform.tenant.module_override.updated", command.TenantId, new
|
||||
{
|
||||
moduleCode,
|
||||
previousMode,
|
||||
item.Mode,
|
||||
item.ExpiresAt,
|
||||
reason
|
||||
});
|
||||
await provider.GetRequiredService<ISecurityEventPublisher>().CapabilityChangedAsync(
|
||||
command.TenantId,
|
||||
moduleCode,
|
||||
"tenant_module_override_changed",
|
||||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
$"tenant-module-override-{item.Id:N}",
|
||||
cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformTenantModuleOverrideItem(
|
||||
item.TenantId, item.ModuleCode, item.Mode, item.ExpiresAt, item.Reason);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDomainList> GetDomainsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
@@ -768,7 +886,8 @@ internal sealed class PlatformAdminService(
|
||||
SystemScopeCallerType.Platform,
|
||||
nameof(PlatformAdminService),
|
||||
reason,
|
||||
Guid.NewGuid().ToString("N")),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
IsGlobal: true),
|
||||
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,15 @@ internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapa
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = moduleCode.Trim().ToLowerInvariant();
|
||||
if (!ProductModuleCatalog.Contains(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var moduleExists = await dbContext.ProductModules.AsNoTracking()
|
||||
.AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken);
|
||||
if (!moduleExists)
|
||||
{
|
||||
// Compatibility while the fixed module catalog is introduced module-by-module.
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
var tenantActive = await dbContext.Tenants.AsNoTracking()
|
||||
|
||||
@@ -82,6 +82,22 @@ public sealed class TenantExecutionScope(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Caller);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.CorrelationId);
|
||||
if (request.TargetTenantId is null && !request.IsGlobal)
|
||||
{
|
||||
throw new ArgumentException("System scope requires a target tenant or an explicit global declaration.", nameof(request));
|
||||
}
|
||||
if (request.TargetTenantId is not null && request.IsGlobal)
|
||||
{
|
||||
throw new ArgumentException("A tenant-targeted system scope cannot also be global.", nameof(request));
|
||||
}
|
||||
if (request.IsGlobal && request.CallerType is SystemScopeCallerType.Worker or SystemScopeCallerType.PublicQuestionBank)
|
||||
{
|
||||
throw new ArgumentException("Worker and public question bank scopes must target a tenant.", nameof(request));
|
||||
}
|
||||
if (!Enum.IsDefined(request.CallerType))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Unknown system scope caller type.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteAuditAsync(
|
||||
@@ -105,6 +121,7 @@ public sealed class TenantExecutionScope(
|
||||
request.Caller,
|
||||
request.Reason,
|
||||
request.CorrelationId,
|
||||
request.IsGlobal,
|
||||
startedAt,
|
||||
elapsedMilliseconds,
|
||||
failureType
|
||||
|
||||
Reference in New Issue
Block a user