forked from gongxuegit/tiku-backend.net
feat(security): complete capability messaging workflows
This commit is contained in:
@@ -51,6 +51,9 @@ public static class DependencyInjection
|
|||||||
!string.IsNullOrWhiteSpace(options.Username) &&
|
!string.IsNullOrWhiteSpace(options.Username) &&
|
||||||
!string.IsNullOrWhiteSpace(options.Password)),
|
!string.IsNullOrWhiteSpace(options.Password)),
|
||||||
"Production RabbitMQ requires a valid Host, Username and Password.")
|
"Production RabbitMQ requires a valid Host, Username and Password.")
|
||||||
|
.Validate(
|
||||||
|
options => options.OutboxBacklogAlertCount > 0 && options.OutboxOldestMessageAlertSeconds > 0,
|
||||||
|
"RabbitMQ outbox alert thresholds must be positive.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
builder.Services.AddSingleton(messaging);
|
builder.Services.AddSingleton(messaging);
|
||||||
if (messaging.IsConfigured)
|
if (messaging.IsConfigured)
|
||||||
|
|||||||
@@ -140,6 +140,27 @@ public sealed class UpsertPlatformSubscriptionDto
|
|||||||
Metadata);
|
Metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ReplacePlatformPlanModulesDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public IReadOnlyCollection<string> ModuleCodes { get; set; } = [];
|
||||||
|
|
||||||
|
public ReplacePlatformPlanModulesCommand ToCommand(string planCode) => new(planCode, ModuleCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpsertPlatformTenantModuleOverrideDto
|
||||||
|
{
|
||||||
|
public TenantModuleOverrideMode Mode { get; set; }
|
||||||
|
public DateTimeOffset? ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(1000, MinimumLength = 1)]
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public UpsertPlatformTenantModuleOverrideCommand ToCommand(Guid tenantId, string moduleCode) =>
|
||||||
|
new(tenantId, moduleCode, Mode, ExpiresAt, Reason);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class UpsertPlatformStaffDto
|
public sealed class UpsertPlatformStaffDto
|
||||||
{
|
{
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ public sealed class HealthController(
|
|||||||
var outboxPending = database
|
var outboxPending = database
|
||||||
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
|
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
|
||||||
: -1;
|
: -1;
|
||||||
|
var outboxOldestSentTime = database
|
||||||
|
? await dbContext.Set<OutboxMessage>()
|
||||||
|
.Select(message => (DateTime?)message.SentTime)
|
||||||
|
.MinAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
var outboxOldestAgeSeconds = outboxOldestSentTime is null
|
||||||
|
? 0
|
||||||
|
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(outboxOldestSentTime.Value)).TotalSeconds);
|
||||||
|
var outboxAlert = outboxPending >= messagingOptions.OutboxBacklogAlertCount ||
|
||||||
|
outboxOldestAgeSeconds >= messagingOptions.OutboxOldestMessageAlertSeconds;
|
||||||
var ready = database && redis && rabbitMq;
|
var ready = database && redis && rabbitMq;
|
||||||
var response = new
|
var response = new
|
||||||
{
|
{
|
||||||
@@ -52,7 +62,14 @@ public sealed class HealthController(
|
|||||||
database,
|
database,
|
||||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||||
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
|
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
|
||||||
outbox = new { pending = outboxPending },
|
outbox = new
|
||||||
|
{
|
||||||
|
pending = outboxPending,
|
||||||
|
oldestAgeSeconds = Math.Round(outboxOldestAgeSeconds, 1),
|
||||||
|
alert = outboxAlert,
|
||||||
|
backlogAlertCount = messagingOptions.OutboxBacklogAlertCount,
|
||||||
|
oldestMessageAlertSeconds = messagingOptions.OutboxOldestMessageAlertSeconds
|
||||||
|
},
|
||||||
checkedAt = DateTimeOffset.UtcNow
|
checkedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ public sealed class PlatformAdminController(
|
|||||||
return Ok(await platformAdminService.GetPlansAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
return Ok(await platformAdminService.GetPlansAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("plans/{planCode}/modules")]
|
||||||
|
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||||
|
[EndpointSummary("替换 SaaS 套餐模块权益")]
|
||||||
|
[ProducesResponseType<PlatformPlanModuleEntitlements>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PlatformPlanModuleEntitlements>> ReplacePlanModules(
|
||||||
|
string planCode,
|
||||||
|
ReplacePlatformPlanModulesDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await platformAdminService.ReplacePlanModulesAsync(
|
||||||
|
ResolveActor(), request.ToCommand(planCode), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("subscriptions")]
|
[HttpPost("subscriptions")]
|
||||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||||
[EndpointSummary("创建或调整租户订阅")]
|
[EndpointSummary("创建或调整租户订阅")]
|
||||||
@@ -100,6 +113,20 @@ public sealed class PlatformAdminController(
|
|||||||
return Ok(await platformAdminService.UpsertSubscriptionAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
return Ok(await platformAdminService.UpsertSubscriptionAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("tenants/{tenantId:guid}/module-overrides/{moduleCode}")]
|
||||||
|
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||||
|
[EndpointSummary("设置租户模块覆盖")]
|
||||||
|
[ProducesResponseType<PlatformTenantModuleOverrideItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PlatformTenantModuleOverrideItem>> UpsertTenantModuleOverride(
|
||||||
|
Guid tenantId,
|
||||||
|
string moduleCode,
|
||||||
|
UpsertPlatformTenantModuleOverrideDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await platformAdminService.UpsertTenantModuleOverrideAsync(
|
||||||
|
ResolveActor(), request.ToCommand(tenantId, moduleCode), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("domains")]
|
[HttpGet("domains")]
|
||||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||||
[EndpointSummary("查询租户域名状态")]
|
[EndpointSummary("查询租户域名状态")]
|
||||||
|
|||||||
@@ -72,7 +72,9 @@
|
|||||||
"Host": "",
|
"Host": "",
|
||||||
"VirtualHost": "/",
|
"VirtualHost": "/",
|
||||||
"Username": "",
|
"Username": "",
|
||||||
"Password": ""
|
"Password": "",
|
||||||
|
"OutboxBacklogAlertCount": 1000,
|
||||||
|
"OutboxOldestMessageAlertSeconds": 300
|
||||||
},
|
},
|
||||||
"BrowserAuth": {
|
"BrowserAuth": {
|
||||||
"AllowedOrigins": []
|
"AllowedOrigins": []
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ public interface IBackgroundJobService
|
|||||||
Task<int> ProcessPendingAsync(
|
Task<int> ProcessPendingAsync(
|
||||||
string workerId,
|
string workerId,
|
||||||
int batchSize,
|
int batchSize,
|
||||||
|
bool includeImmediateJobs = true,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> ProcessRequestedAsync(
|
||||||
|
Guid jobId,
|
||||||
|
Guid tenantId,
|
||||||
|
string jobType,
|
||||||
|
string workerId,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
||||||
@@ -41,3 +49,15 @@ public interface IBackgroundJobService
|
|||||||
int limit = 50,
|
int limit = 50,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IBackgroundJobDispatcher
|
||||||
|
{
|
||||||
|
bool IsEnabled { get; }
|
||||||
|
|
||||||
|
Task DispatchAsync(
|
||||||
|
Guid jobId,
|
||||||
|
Guid tenantId,
|
||||||
|
string jobType,
|
||||||
|
string correlationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,28 @@ public sealed record UpsertPlatformTenantBillingProfileCommand(
|
|||||||
|
|
||||||
public sealed record PlatformPlanList(IReadOnlyCollection<PlatformSaasPlan> Items);
|
public sealed record PlatformPlanList(IReadOnlyCollection<PlatformSaasPlan> Items);
|
||||||
|
|
||||||
|
public sealed record PlatformPlanModuleEntitlements(
|
||||||
|
string PlanCode,
|
||||||
|
IReadOnlyCollection<string> ModuleCodes);
|
||||||
|
|
||||||
|
public sealed record ReplacePlatformPlanModulesCommand(
|
||||||
|
string PlanCode,
|
||||||
|
IReadOnlyCollection<string> ModuleCodes);
|
||||||
|
|
||||||
|
public sealed record PlatformTenantModuleOverrideItem(
|
||||||
|
Guid TenantId,
|
||||||
|
string ModuleCode,
|
||||||
|
TenantModuleOverrideMode Mode,
|
||||||
|
DateTimeOffset? ExpiresAt,
|
||||||
|
string? Reason);
|
||||||
|
|
||||||
|
public sealed record UpsertPlatformTenantModuleOverrideCommand(
|
||||||
|
Guid TenantId,
|
||||||
|
string ModuleCode,
|
||||||
|
TenantModuleOverrideMode Mode,
|
||||||
|
DateTimeOffset? ExpiresAt,
|
||||||
|
string Reason);
|
||||||
|
|
||||||
public sealed record UpsertPlatformSubscriptionCommand(
|
public sealed record UpsertPlatformSubscriptionCommand(
|
||||||
Guid TenantId,
|
Guid TenantId,
|
||||||
string PlanCode,
|
string PlanCode,
|
||||||
@@ -239,7 +261,9 @@ public interface IPlatformAdminService
|
|||||||
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
|
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
|
||||||
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
|
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
|
||||||
Task<PlatformPlanList> GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
Task<PlatformPlanList> GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
|
Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(PlatformAdminActor actor, ReplacePlatformPlanModulesCommand command, CancellationToken cancellationToken = default);
|
||||||
Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
|
Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
|
||||||
|
Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(PlatformAdminActor actor, UpsertPlatformTenantModuleOverrideCommand command, CancellationToken cancellationToken = default);
|
||||||
Task<PlatformDomainList> GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
Task<PlatformDomainList> GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
Task<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
|
Task<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
|
||||||
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
15
Tiku.Application/Security/ConsumerAuthorizationMetadata.cs
Normal file
15
Tiku.Application/Security/ConsumerAuthorizationMetadata.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Tiku.Application.Security;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||||
|
public sealed class ConsumerAuthorizationMetadataAttribute(
|
||||||
|
string realm,
|
||||||
|
string module,
|
||||||
|
CapabilityOperation operation,
|
||||||
|
string auditAction) : Attribute
|
||||||
|
{
|
||||||
|
public string Realm { get; } = realm;
|
||||||
|
public string Module { get; } = module;
|
||||||
|
public CapabilityOperation Operation { get; } = operation;
|
||||||
|
public string AuditAction { get; } = auditAction;
|
||||||
|
public bool RequiresSystemScope { get; init; }
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ public sealed record SystemScopeRequest(
|
|||||||
SystemScopeCallerType CallerType,
|
SystemScopeCallerType CallerType,
|
||||||
string Caller,
|
string Caller,
|
||||||
string Reason,
|
string Reason,
|
||||||
string CorrelationId);
|
string CorrelationId,
|
||||||
|
bool IsGlobal = false);
|
||||||
|
|
||||||
public interface ITenantExecutionScope
|
public interface ITenantExecutionScope
|
||||||
{
|
{
|
||||||
|
|||||||
23
Tiku.Application/Security/ProductModuleCatalog.cs
Normal file
23
Tiku.Application/Security/ProductModuleCatalog.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Tiku.Application.Security;
|
||||||
|
|
||||||
|
public static class ProductModuleCatalog
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyDictionary<string, string> All =
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["dashboard"] = "Dashboard",
|
||||||
|
["staff"] = "Staff",
|
||||||
|
["role"] = "Roles",
|
||||||
|
["student"] = "Students",
|
||||||
|
["content"] = "Content",
|
||||||
|
["settings"] = "Settings",
|
||||||
|
["provider"] = "Providers",
|
||||||
|
["commerce"] = "Commerce",
|
||||||
|
["crm"] = "CRM",
|
||||||
|
["commission"] = "Commission",
|
||||||
|
["job"] = "Background Jobs"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool Contains(string moduleCode) =>
|
||||||
|
All.ContainsKey(moduleCode.Trim().ToLowerInvariant());
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||||
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
||||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||||
|
services.AddSingleton<IBackgroundJobDispatcher, NullBackgroundJobDispatcher>();
|
||||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||||
services.AddScoped<ICommerceService, CommerceService>();
|
services.AddScoped<ICommerceService, CommerceService>();
|
||||||
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
services.AddScoped<ICommerceAdminService, CommerceAdminService>();
|
||||||
@@ -209,6 +210,12 @@ public static class DependencyInjection
|
|||||||
consumer.UseMessageRetry(retry => retry.Intervals(
|
consumer.UseMessageRetry(retry => retry.Intervals(
|
||||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
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) =>
|
registration.AddConfigureEndpointsCallback((context, _, endpoint) =>
|
||||||
{
|
{
|
||||||
endpoint.PrefetchCount = 1;
|
endpoint.PrefetchCount = 1;
|
||||||
@@ -228,6 +235,7 @@ public static class DependencyInjection
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
services.AddScoped<ISecurityEventPublisher, MassTransitSecurityEventPublisher>();
|
services.AddScoped<ISecurityEventPublisher, MassTransitSecurityEventPublisher>();
|
||||||
|
services.AddScoped<IBackgroundJobDispatcher, MassTransitBackgroundJobDispatcher>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ namespace Tiku.Infrastructure.Jobs;
|
|||||||
|
|
||||||
internal sealed class BackgroundJobService(
|
internal sealed class BackgroundJobService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IServiceProvider serviceProvider,
|
|
||||||
ITenantExecutionScope tenantExecutionScope,
|
ITenantExecutionScope tenantExecutionScope,
|
||||||
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackgroundJobService
|
ICapabilityAccessEvaluator capabilityAccessEvaluator,
|
||||||
|
IBackgroundJobDispatcher backgroundJobDispatcher) : IBackgroundJobService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
@@ -43,6 +43,15 @@ internal sealed class BackgroundJobService(
|
|||||||
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
||||||
};
|
};
|
||||||
dbContext.BackgroundJobs.Add(job);
|
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);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return ToItem(job);
|
return ToItem(job);
|
||||||
}
|
}
|
||||||
@@ -50,12 +59,14 @@ internal sealed class BackgroundJobService(
|
|||||||
public async Task<int> ProcessPendingAsync(
|
public async Task<int> ProcessPendingAsync(
|
||||||
string workerId,
|
string workerId,
|
||||||
int batchSize,
|
int batchSize,
|
||||||
|
bool includeImmediateJobs = true,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var jobs = await dbContext.BackgroundJobs
|
var jobs = await dbContext.BackgroundJobs
|
||||||
.Where(job =>
|
.Where(job =>
|
||||||
job.Status == BackgroundJobStatus.Pending &&
|
job.Status == BackgroundJobStatus.Pending &&
|
||||||
|
(includeImmediateJobs || job.RunAfter != null) &&
|
||||||
(job.RunAfter == null || job.RunAfter <= now))
|
(job.RunAfter == null || job.RunAfter <= now))
|
||||||
.OrderBy(job => job.CreatedAt)
|
.OrderBy(job => job.CreatedAt)
|
||||||
.Take(Math.Clamp(batchSize, 1, 100))
|
.Take(Math.Clamp(batchSize, 1, 100))
|
||||||
@@ -65,6 +76,48 @@ internal sealed class BackgroundJobService(
|
|||||||
foreach (var job in jobs)
|
foreach (var job in jobs)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
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(
|
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||||
job.TenantId,
|
job.TenantId,
|
||||||
ResolveCapabilityModule(job.JobType),
|
ResolveCapabilityModule(job.JobType),
|
||||||
@@ -75,9 +128,10 @@ internal sealed class BackgroundJobService(
|
|||||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
job.LastError = "Tenant capability was revoked before job execution.";
|
job.LastError = "Tenant capability was revoked before job execution.";
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
processed++;
|
return true;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
job.Status = BackgroundJobStatus.Processing;
|
job.Status = BackgroundJobStatus.Processing;
|
||||||
job.LockedBy = workerId;
|
job.LockedBy = workerId;
|
||||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||||
@@ -104,18 +158,18 @@ internal sealed class BackgroundJobService(
|
|||||||
job.Status = job.RetryCount > job.MaxRetries
|
job.Status = job.RetryCount > job.MaxRetries
|
||||||
? BackgroundJobStatus.Failed
|
? BackgroundJobStatus.Failed
|
||||||
: BackgroundJobStatus.Pending;
|
: BackgroundJobStatus.Pending;
|
||||||
job.RunAfter = DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount));
|
job.RunAfter = job.Status == BackgroundJobStatus.Pending
|
||||||
|
? DateTimeOffset.UtcNow.AddSeconds(Math.Min(300, 10 * job.RetryCount))
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
job.LockedBy = null;
|
job.LockedBy = null;
|
||||||
job.LockExpiresAt = null;
|
job.LockExpiresAt = null;
|
||||||
processed++;
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return processed;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
||||||
@@ -145,14 +199,15 @@ internal sealed class BackgroundJobService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var scopedDbContext = scopedProvider.GetRequiredService<TikuDbContext>();
|
||||||
return job.JobType switch
|
return job.JobType switch
|
||||||
{
|
{
|
||||||
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
|
"content_export" => await ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
|
||||||
"content_import" => await ProcessContentImportAsync(scopedProvider, 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."),
|
"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),
|
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedDbContext, job, cancellationToken),
|
||||||
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
|
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
|
||||||
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(cancellationToken),
|
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken),
|
||||||
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
|
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -199,12 +254,13 @@ internal sealed class BackgroundJobService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement> ProcessContentExportAsync(
|
private async Task<JsonElement> ProcessContentExportAsync(
|
||||||
|
TikuDbContext scopedDbContext,
|
||||||
BackgroundJob job,
|
BackgroundJob job,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var exportType = GetJsonString(job.Payload, "exportType") ?? "summary";
|
var exportType = GetJsonString(job.Payload, "exportType") ?? "summary";
|
||||||
var assetKey = $"background-jobs/{job.Id:N}/content-export.json";
|
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,
|
item => item.TenantId == job.TenantId && item.AssetKey == assetKey,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (asset is null)
|
if (asset is null)
|
||||||
@@ -219,12 +275,12 @@ internal sealed class BackgroundJobService(
|
|||||||
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
|
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
|
||||||
Source = "background_job"
|
Source = "background_job"
|
||||||
};
|
};
|
||||||
dbContext.ContentAssets.Add(asset);
|
scopedDbContext.ContentAssets.Add(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
var questionBankCount = await dbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
var questionBankCount = await scopedDbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||||
var questionCount = await dbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
var questionCount = await scopedDbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||||
var studentCount = await dbContext.StudentProfiles.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.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||||
asset.Title = "Content export manifest";
|
asset.Title = "Content export manifest";
|
||||||
asset.Description = $"Generated content export manifest for {exportType}.";
|
asset.Description = $"Generated content export manifest for {exportType}.";
|
||||||
@@ -239,7 +295,7 @@ internal sealed class BackgroundJobService(
|
|||||||
studentCount,
|
studentCount,
|
||||||
payload = job.Payload
|
payload = job.Payload
|
||||||
});
|
});
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||||
job.OutputAssetId = asset.Id;
|
job.OutputAssetId = asset.Id;
|
||||||
return JsonSerializer.SerializeToElement(new
|
return JsonSerializer.SerializeToElement(new
|
||||||
{
|
{
|
||||||
@@ -252,11 +308,12 @@ internal sealed class BackgroundJobService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement> ProcessCommerceReconciliationAsync(
|
private async Task<JsonElement> ProcessCommerceReconciliationAsync(
|
||||||
|
TikuDbContext scopedDbContext,
|
||||||
BackgroundJob job,
|
BackgroundJob job,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var provider = NormalizeProvider(GetJsonString(job.Payload, "provider"));
|
var provider = NormalizeProvider(GetJsonString(job.Payload, "provider"));
|
||||||
var hasProviderConfig = await dbContext.TenantExternalProviders.AnyAsync(
|
var hasProviderConfig = await scopedDbContext.TenantExternalProviders.AnyAsync(
|
||||||
item =>
|
item =>
|
||||||
item.TenantId == job.TenantId &&
|
item.TenantId == job.TenantId &&
|
||||||
item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment &&
|
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 billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date);
|
||||||
var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined);
|
var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined);
|
||||||
var sourceHash = $"background-job:{job.Id:N}";
|
var sourceHash = $"background-job:{job.Id:N}";
|
||||||
var batch = await dbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
|
var batch = await scopedDbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
|
||||||
item =>
|
item =>
|
||||||
item.TenantId == job.TenantId &&
|
item.TenantId == job.TenantId &&
|
||||||
item.Provider == provider &&
|
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."
|
note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
dbContext.CommerceReconciliationBatches.Add(batch);
|
scopedDbContext.CommerceReconciliationBatches.Add(batch);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonSerializer.SerializeToElement(new
|
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);
|
var processed = await lifecycleService.ProcessPendingAsync(cancellationToken);
|
||||||
return JsonSerializer.SerializeToElement(new
|
return JsonSerializer.SerializeToElement(new
|
||||||
{
|
{
|
||||||
@@ -321,18 +380,19 @@ internal sealed class BackgroundJobService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
|
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
|
||||||
|
TikuDbContext scopedDbContext,
|
||||||
BackgroundJob job,
|
BackgroundJob job,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var since = DateTimeOffset.UtcNow.AddDays(-7);
|
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)
|
.Where(item => item.TenantId == job.TenantId && item.StartedAt >= since)
|
||||||
.Select(item => item.UserId)
|
.Select(item => item.UserId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.CountAsync(cancellationToken);
|
.CountAsync(cancellationToken);
|
||||||
var paidOrderCount = await dbContext.Orders
|
var paidOrderCount = await scopedDbContext.Orders
|
||||||
.CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken);
|
.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 &&
|
.Where(item => item.TenantId == job.TenantId &&
|
||||||
(item.Status == OrderStatus.Paid ||
|
(item.Status == OrderStatus.Paid ||
|
||||||
item.Status == OrderStatus.PartiallyRefunded ||
|
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 Username { get; set; } = string.Empty;
|
||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
public bool ConfigureConsumers { get; set; }
|
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) &&
|
public bool IsConfigured => Uri.TryCreate(Host, UriKind.Absolute, out var uri) &&
|
||||||
uri.Scheme is "rabbitmq" or "amqp" or "amqps";
|
uri.Scheme is "rabbitmq" or "amqp" or "amqps";
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Tiku.Contracts;
|
|||||||
|
|
||||||
namespace Tiku.Infrastructure.Messaging;
|
namespace Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
[ConsumerAuthorizationMetadata(
|
||||||
|
"system", "security-state", CapabilityOperation.Read, "security.invalidation.apply")]
|
||||||
internal sealed class SecurityStateChangedConsumer(IRedisSecurityStore redisSecurityStore) :
|
internal sealed class SecurityStateChangedConsumer(IRedisSecurityStore redisSecurityStore) :
|
||||||
IConsumer<AuthorizationStateChangedV1>,
|
IConsumer<AuthorizationStateChangedV1>,
|
||||||
IConsumer<TenantCapabilityChangedV1>,
|
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);
|
}, 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(
|
public async Task<PlatformDomainList> GetDomainsAsync(
|
||||||
PlatformAdminActor actor,
|
PlatformAdminActor actor,
|
||||||
PlatformAdminQuery query,
|
PlatformAdminQuery query,
|
||||||
@@ -768,7 +886,8 @@ internal sealed class PlatformAdminService(
|
|||||||
SystemScopeCallerType.Platform,
|
SystemScopeCallerType.Platform,
|
||||||
nameof(PlatformAdminService),
|
nameof(PlatformAdminService),
|
||||||
reason,
|
reason,
|
||||||
Guid.NewGuid().ToString("N")),
|
Guid.NewGuid().ToString("N"),
|
||||||
|
IsGlobal: true),
|
||||||
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapa
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var normalized = moduleCode.Trim().ToLowerInvariant();
|
var normalized = moduleCode.Trim().ToLowerInvariant();
|
||||||
|
if (!ProductModuleCatalog.Contains(normalized))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
var moduleExists = await dbContext.ProductModules.AsNoTracking()
|
var moduleExists = await dbContext.ProductModules.AsNoTracking()
|
||||||
.AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken);
|
.AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken);
|
||||||
if (!moduleExists)
|
if (!moduleExists)
|
||||||
{
|
{
|
||||||
// Compatibility while the fixed module catalog is introduced module-by-module.
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantActive = await dbContext.Tenants.AsNoTracking()
|
var tenantActive = await dbContext.Tenants.AsNoTracking()
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ public sealed class TenantExecutionScope(
|
|||||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Caller);
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.Caller);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.CorrelationId);
|
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(
|
private static async Task WriteAuditAsync(
|
||||||
@@ -105,6 +121,7 @@ public sealed class TenantExecutionScope(
|
|||||||
request.Caller,
|
request.Caller,
|
||||||
request.Reason,
|
request.Reason,
|
||||||
request.CorrelationId,
|
request.CorrelationId,
|
||||||
|
request.IsGlobal,
|
||||||
startedAt,
|
startedAt,
|
||||||
elapsedMilliseconds,
|
elapsedMilliseconds,
|
||||||
failureType
|
failureType
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ using Tiku.Domain.Identity;
|
|||||||
using Tiku.Domain.Operations;
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
|
using Tiku.Domain.Platform;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.IntegrationTests.Infrastructure;
|
using Tiku.IntegrationTests.Infrastructure;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
@@ -134,6 +136,54 @@ public sealed class ApiTestFactory(
|
|||||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||||
.InitializeSystem(null, "Integration test fixture seeding");
|
.InitializeSystem(null, "Integration test fixture seeding");
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var tenants = entities.OfType<Tenant>().Where(tenant => tenant.Mode == TenantMode.Saas).ToArray();
|
||||||
|
var hasExplicitCapabilitySetup = entities.Any(entity =>
|
||||||
|
entity is PlatformSaasPlan or ProductModule or PlanModuleEntitlement or TenantSubscription);
|
||||||
|
if (tenants.Length > 0 && !hasExplicitCapabilitySetup)
|
||||||
|
{
|
||||||
|
const string integrationPlanCode = "integration-full-access";
|
||||||
|
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == integrationPlanCode))
|
||||||
|
{
|
||||||
|
dbContext.PlatformSaasPlans.Add(new PlatformSaasPlan
|
||||||
|
{
|
||||||
|
Code = integrationPlanCode,
|
||||||
|
Name = "Integration Full Access"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingModules = await dbContext.ProductModules
|
||||||
|
.Select(module => module.Code)
|
||||||
|
.ToArrayAsync();
|
||||||
|
foreach (var module in ProductModuleCatalog.All.Where(module => !existingModules.Contains(module.Key)))
|
||||||
|
{
|
||||||
|
dbContext.ProductModules.Add(new ProductModule { Code = module.Key, Name = module.Value });
|
||||||
|
}
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var entitledModules = await dbContext.PlanModuleEntitlements
|
||||||
|
.Where(entitlement => entitlement.PlanCode == integrationPlanCode)
|
||||||
|
.Select(entitlement => entitlement.ModuleCode)
|
||||||
|
.ToArrayAsync();
|
||||||
|
foreach (var moduleCode in ProductModuleCatalog.All.Keys.Except(entitledModules, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
|
||||||
|
{
|
||||||
|
PlanCode = integrationPlanCode,
|
||||||
|
ModuleCode = moduleCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
entities = entities.Concat(tenants.Select(tenant => new TenantSubscription
|
||||||
|
{
|
||||||
|
TenantId = tenant.Id,
|
||||||
|
PlanCode = integrationPlanCode,
|
||||||
|
Status = TenantSubscriptionStatus.Active,
|
||||||
|
StartsAt = now.AddDays(-1),
|
||||||
|
ExpiresAt = now.AddYears(1)
|
||||||
|
})).ToArray();
|
||||||
|
}
|
||||||
dbContext.AddRange(entities);
|
dbContext.AddRange(entities);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ using Microsoft.AspNetCore.Mvc.Routing;
|
|||||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MassTransit;
|
||||||
using Tiku.Api.Security;
|
using Tiku.Api.Security;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
namespace Tiku.IntegrationTests.Api;
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
public sealed class AuthorizationManifestTests
|
public sealed class AuthorizationManifestTests
|
||||||
{
|
{
|
||||||
private const int ExpectedActionCount = 330;
|
private const int ExpectedActionCount = 332;
|
||||||
private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5";
|
private const string ExpectedSha256 = "a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||||
@@ -61,6 +64,30 @@ public sealed class AuthorizationManifestTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Message_consumers_have_reviewed_authorization_and_audit_metadata()
|
||||||
|
{
|
||||||
|
var consumers = typeof(MessagingOptions).Assembly.GetTypes()
|
||||||
|
.Where(type => !type.IsAbstract && type.GetInterfaces().Any(candidate =>
|
||||||
|
candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IConsumer<>)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(2, consumers.Length);
|
||||||
|
foreach (var consumer in consumers)
|
||||||
|
{
|
||||||
|
var metadata = consumer.GetCustomAttribute<ConsumerAuthorizationMetadataAttribute>();
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Contains(metadata.Realm, new[] { "tenant", "platform", "system" });
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(metadata.Module));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction));
|
||||||
|
if (consumer.Name == "BackgroundJobRequestedConsumer")
|
||||||
|
{
|
||||||
|
Assert.Equal(CapabilityOperation.Write, metadata.Operation);
|
||||||
|
Assert.True(metadata.RequiresSystemScope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string Describe(Type controller, MethodInfo action)
|
private static string Describe(Type controller, MethodInfo action)
|
||||||
{
|
{
|
||||||
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;
|
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Application.Jobs;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Commerce;
|
using Tiku.Domain.Commerce;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Domain.Platform;
|
using Tiku.Domain.Platform;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
@@ -9,6 +13,48 @@ namespace Tiku.IntegrationTests.Api;
|
|||||||
|
|
||||||
public sealed class CapabilityAuthorizationTests
|
public sealed class CapabilityAuthorizationTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Background_job_rechecks_capability_after_enqueue_before_execution()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Job Capability Tenant" },
|
||||||
|
new PlatformSaasPlan { Code = "job-capability-test", Name = "Job Capability Test" },
|
||||||
|
new PlanModuleEntitlement { PlanCode = "job-capability-test", ModuleCode = "content" },
|
||||||
|
new TenantSubscription
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
PlanCode = "job-capability-test",
|
||||||
|
Status = TenantSubscriptionStatus.Active,
|
||||||
|
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||||
|
});
|
||||||
|
|
||||||
|
using var scope = factory.CreateSystemScope("Verify job capability at consumption");
|
||||||
|
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||||
|
var job = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||||
|
tenantId,
|
||||||
|
"content_export",
|
||||||
|
JsonSerializer.SerializeToElement(new { exportType = "capability-test" })));
|
||||||
|
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
dbContext.TenantModuleOverrides.Add(new TenantModuleOverride
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
ModuleCode = "content",
|
||||||
|
Mode = TenantModuleOverrideMode.Disabled,
|
||||||
|
Reason = "Integration test revocation"
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.True(await jobs.ProcessRequestedAsync(
|
||||||
|
job.Id, tenantId, job.JobType, "capability-test-worker"));
|
||||||
|
var stored = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id);
|
||||||
|
Assert.Equal(BackgroundJobStatus.Failed, stored.Status);
|
||||||
|
Assert.Equal("Tenant capability was revoked before job execution.", stored.LastError);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
|
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
|
||||||
{
|
{
|
||||||
@@ -17,7 +63,6 @@ public sealed class CapabilityAuthorizationTests
|
|||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
|
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
|
||||||
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
|
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
|
||||||
new ProductModule { Code = "content", Name = "Content" },
|
|
||||||
new TenantSubscription
|
new TenantSubscription
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ public sealed class PlatformAdminEndpointTests
|
|||||||
|
|
||||||
var overview = await client.GetAsync("/api/platform-admin/overview");
|
var overview = await client.GetAsync("/api/platform-admin/overview");
|
||||||
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
|
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
|
||||||
|
var planModules = await client.PutAsJsonAsync(
|
||||||
|
"/api/platform-admin/plans/standard/modules",
|
||||||
|
new ReplacePlatformPlanModulesDto { ModuleCodes = ["content", "job", "settings"] });
|
||||||
var subscription = await client.PostAsJsonAsync(
|
var subscription = await client.PostAsJsonAsync(
|
||||||
"/api/platform-admin/subscriptions",
|
"/api/platform-admin/subscriptions",
|
||||||
new UpsertPlatformSubscriptionDto
|
new UpsertPlatformSubscriptionDto
|
||||||
@@ -73,6 +76,13 @@ public sealed class PlatformAdminEndpointTests
|
|||||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||||
AmountCents = 99900
|
AmountCents = 99900
|
||||||
});
|
});
|
||||||
|
var moduleOverride = await client.PutAsJsonAsync(
|
||||||
|
$"/api/platform-admin/tenants/{tenantId}/module-overrides/content",
|
||||||
|
new UpsertPlatformTenantModuleOverrideDto
|
||||||
|
{
|
||||||
|
Mode = TenantModuleOverrideMode.Disabled,
|
||||||
|
Reason = "integration test capability revocation"
|
||||||
|
});
|
||||||
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
|
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
|
||||||
var suspended = await client.PatchAsJsonAsync(
|
var suspended = await client.PatchAsJsonAsync(
|
||||||
"/api/platform-admin/tenants/status",
|
"/api/platform-admin/tenants/status",
|
||||||
@@ -90,7 +100,9 @@ public sealed class PlatformAdminEndpointTests
|
|||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, planModules.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, moduleOverride.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
|
||||||
@@ -103,6 +115,12 @@ public sealed class PlatformAdminEndpointTests
|
|||||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||||
log.ActorUserId == platform.UserId &&
|
log.ActorUserId == platform.UserId &&
|
||||||
log.Action == "platform.tenant.status_changed"));
|
log.Action == "platform.tenant.status_changed"));
|
||||||
|
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||||
|
log.ActorUserId == platform.UserId &&
|
||||||
|
log.Action == "platform.plan.modules.replaced"));
|
||||||
|
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||||
|
log.ActorUserId == platform.UserId &&
|
||||||
|
log.Action == "platform.tenant.module_override.updated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class QuestionBankEndpointTests
|
|||||||
var otherTenantId = Guid.NewGuid();
|
var otherTenantId = Guid.NewGuid();
|
||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
|
PlatformTenant(),
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
Tenant(otherTenantId, "other"),
|
Tenant(otherTenantId, "other"),
|
||||||
new QuestionBank
|
new QuestionBank
|
||||||
@@ -62,6 +63,7 @@ public sealed class QuestionBankEndpointTests
|
|||||||
var versionId = Guid.NewGuid();
|
var versionId = Guid.NewGuid();
|
||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
|
PlatformTenant(),
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" },
|
new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" },
|
||||||
new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" },
|
new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" },
|
||||||
@@ -130,6 +132,7 @@ public sealed class QuestionBankEndpointTests
|
|||||||
var questionId = Guid.NewGuid();
|
var questionId = Guid.NewGuid();
|
||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
|
PlatformTenant(),
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
new Question
|
new Question
|
||||||
{
|
{
|
||||||
@@ -154,6 +157,7 @@ public sealed class QuestionBankEndpointTests
|
|||||||
var questionId = Guid.NewGuid();
|
var questionId = Guid.NewGuid();
|
||||||
await using var factory = new ApiTestFactory();
|
await using var factory = new ApiTestFactory();
|
||||||
await factory.SeedAsync(
|
await factory.SeedAsync(
|
||||||
|
PlatformTenant(),
|
||||||
Tenant(tenantId, "master"),
|
Tenant(tenantId, "master"),
|
||||||
new Question
|
new Question
|
||||||
{
|
{
|
||||||
@@ -199,6 +203,16 @@ public sealed class QuestionBankEndpointTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Tenant PlatformTenant() => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = $"platform-{Guid.NewGuid():N}",
|
||||||
|
Name = "Platform Question Bank",
|
||||||
|
Status = TenantStatus.Active,
|
||||||
|
Mode = TenantMode.PlatformOwned,
|
||||||
|
Metadata = JsonDefaults.Object()
|
||||||
|
};
|
||||||
|
|
||||||
private static async Task<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
private static async Task<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Sockets;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
|
using Tiku.Application.Jobs;
|
||||||
using Tiku.Contracts;
|
using Tiku.Contracts;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
using Tiku.Infrastructure.Messaging;
|
using Tiku.Infrastructure.Messaging;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
@@ -17,6 +22,199 @@ namespace Tiku.IntegrationTests;
|
|||||||
|
|
||||||
public sealed class MassTransitOutboxTests
|
public sealed class MassTransitOutboxTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Bus_outbox_drains_after_real_broker_restart()
|
||||||
|
{
|
||||||
|
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||||
|
var containerName = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_CONTAINER");
|
||||||
|
if (string.IsNullOrWhiteSpace(rabbitMqHost) ||
|
||||||
|
Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_RESTART") != "1" ||
|
||||||
|
string.IsNullOrWhiteSpace(containerName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var factory = CreateRabbitFactory(rabbitMqHost);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready before restart drill.");
|
||||||
|
|
||||||
|
await RunDockerAsync("stop", containerName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(await WaitForBrokerPortClosedAsync(new Uri(rabbitMqHost)),
|
||||||
|
"RabbitMQ AMQP port remained reachable after stopping the test container.");
|
||||||
|
|
||||||
|
using (var scope = factory.CreateSystemScope("Commit outbox while RabbitMQ is stopped"))
|
||||||
|
{
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var publisher = scope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||||
|
await publisher.AuthorizationChangedAsync(
|
||||||
|
null, null, "broker_restart_test", 3, Guid.NewGuid().ToString("N"));
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verification = factory.CreateSystemScope("Verify restart outbox backlog");
|
||||||
|
var verificationDbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.NotEmpty(await verificationDbContext.Set<OutboxMessage>().ToArrayAsync());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await RunDockerAsync("start", containerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(await WaitForReadyAsync(client, 240),
|
||||||
|
"RabbitMQ did not become ready within 60 seconds after restart.");
|
||||||
|
var drained = false;
|
||||||
|
for (var attempt = 0; attempt < 240; attempt++)
|
||||||
|
{
|
||||||
|
using var verification = factory.CreateSystemScope("Wait for post-restart outbox drain");
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
if (!await dbContext.Set<OutboxMessage>().AnyAsync())
|
||||||
|
{
|
||||||
|
drained = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Task.Delay(250);
|
||||||
|
}
|
||||||
|
Assert.True(drained, "Bus outbox did not drain within 60 seconds after RabbitMQ restart.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunDockerAsync(string operation, string containerName)
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "docker",
|
||||||
|
ArgumentList = { operation, containerName },
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false
|
||||||
|
}) ?? throw new InvalidOperationException("Failed to start Docker CLI for RabbitMQ restart drill.");
|
||||||
|
var standardOutput = await process.StandardOutput.ReadToEndAsync();
|
||||||
|
var standardError = await process.StandardError.ReadToEndAsync();
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"docker {operation} failed for the RabbitMQ test container: {standardError}{standardOutput}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> WaitForBrokerPortClosedAsync(Uri broker)
|
||||||
|
{
|
||||||
|
var port = broker.IsDefaultPort ? 5672 : broker.Port;
|
||||||
|
for (var attempt = 0; attempt < 40; attempt++)
|
||||||
|
{
|
||||||
|
using var tcpClient = new TcpClient();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await tcpClient.ConnectAsync(broker.Host, port).WaitAsync(TimeSpan.FromMilliseconds(250));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SocketException or TimeoutException)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await Task.Delay(250);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Background_job_request_is_transactional_consumed_once_and_keeps_database_status_view()
|
||||||
|
{
|
||||||
|
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||||
|
if (string.IsNullOrWhiteSpace(rabbitMqHost))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var factory = CreateRabbitFactory(rabbitMqHost);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready within 10 seconds.");
|
||||||
|
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(new Tenant
|
||||||
|
{
|
||||||
|
Id = tenantId,
|
||||||
|
Slug = tenantId.ToString("N"),
|
||||||
|
Name = "RabbitMQ Background Job Tenant"
|
||||||
|
});
|
||||||
|
|
||||||
|
var workerBuilder = Host.CreateApplicationBuilder();
|
||||||
|
workerBuilder.Services.AddApplication();
|
||||||
|
workerBuilder.Services.AddInfrastructure(factory.DatabaseConnectionString);
|
||||||
|
workerBuilder.Services.AddReliableMessaging(CreateRabbitOptions(rabbitMqHost, configureConsumers: true));
|
||||||
|
using var worker = workerBuilder.Build();
|
||||||
|
await worker.StartAsync();
|
||||||
|
|
||||||
|
Guid rolledBackJobId;
|
||||||
|
using (var scope = factory.CreateSystemScope("Roll back background job request"))
|
||||||
|
{
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||||
|
var job = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||||
|
tenantId, "tenant_domain_recheck", JsonSerializer.SerializeToElement(new { })));
|
||||||
|
rolledBackJobId = job.Id;
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var verification = factory.CreateSystemScope("Verify rolled back background job request"))
|
||||||
|
{
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.False(await dbContext.BackgroundJobs.AnyAsync(item => item.Id == rolledBackJobId));
|
||||||
|
Assert.False(await dbContext.Set<OutboxMessage>().AnyAsync(
|
||||||
|
item => item.MessageId == rolledBackJobId));
|
||||||
|
}
|
||||||
|
|
||||||
|
BackgroundJobItem committedJob;
|
||||||
|
using (var scope = factory.CreateSystemScope("Commit background job request"))
|
||||||
|
{
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||||
|
committedJob = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||||
|
tenantId, "tenant_domain_recheck", JsonSerializer.SerializeToElement(new { })));
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
BackgroundJobStatus? status = null;
|
||||||
|
for (var attempt = 0; attempt < 60; attempt++)
|
||||||
|
{
|
||||||
|
using var verification = factory.CreateSystemScope("Wait for RabbitMQ background job consumer");
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
status = await dbContext.BackgroundJobs
|
||||||
|
.Where(item => item.Id == committedJob.Id)
|
||||||
|
.Select(item => (BackgroundJobStatus?)item.Status)
|
||||||
|
.SingleAsync();
|
||||||
|
if (status == BackgroundJobStatus.Succeeded) break;
|
||||||
|
await Task.Delay(250);
|
||||||
|
}
|
||||||
|
Assert.Equal(BackgroundJobStatus.Succeeded, status);
|
||||||
|
|
||||||
|
using (var verification = factory.CreateSystemScope("Verify background job inbox and status view"))
|
||||||
|
{
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.Single(await dbContext.Set<InboxState>()
|
||||||
|
.Where(item => item.MessageId == committedJob.Id)
|
||||||
|
.ToArrayAsync());
|
||||||
|
var jobs = verification.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||||
|
var statusView = await jobs.ListAsync(tenantId, "tenant_domain_recheck");
|
||||||
|
Assert.Contains(statusView, item =>
|
||||||
|
item.Id == committedJob.Id && item.Status == BackgroundJobStatus.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
var managementEndpoint = ResolveRabbitManagementEndpoint(rabbitMqHost);
|
||||||
|
if (managementEndpoint is not null)
|
||||||
|
{
|
||||||
|
Assert.Equal(0, await GetQueueMessageCountAsync(
|
||||||
|
managementEndpoint, "background-job-requested_error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await worker.StopAsync();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version()
|
public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version()
|
||||||
{
|
{
|
||||||
@@ -177,9 +375,9 @@ public sealed class MassTransitOutboxTests
|
|||||||
ConfigureConsumers = configureConsumers
|
ConfigureConsumers = configureConsumers
|
||||||
};
|
};
|
||||||
|
|
||||||
private static async Task<bool> WaitForReadyAsync(HttpClient client)
|
private static async Task<bool> WaitForReadyAsync(HttpClient client, int attempts = 40)
|
||||||
{
|
{
|
||||||
for (var attempt = 0; attempt < 40; attempt++)
|
for (var attempt = 0; attempt < attempts; attempt++)
|
||||||
{
|
{
|
||||||
if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK)
|
if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
@@ -210,6 +408,10 @@ public sealed class MassTransitOutboxTests
|
|||||||
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
|
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
using var response = await client.GetAsync($"/api/queues/%2F/{Uri.EscapeDataString(queueName)}");
|
using var response = await client.GetAsync($"/api/queues/%2F/{Uri.EscapeDataString(queueName)}");
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
return document.RootElement.GetProperty("messages").GetInt32();
|
return document.RootElement.GetProperty("messages").GetInt32();
|
||||||
|
|||||||
@@ -16,10 +16,20 @@ public sealed class SystemScopeAuditTests
|
|||||||
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
|
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
|
||||||
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"),
|
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"),
|
||||||
(_, _) => Task.CompletedTask));
|
(_, _) => Task.CompletedTask));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
|
||||||
|
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "missing target", "job-2"),
|
||||||
|
(_, _) => Task.CompletedTask));
|
||||||
|
|
||||||
var correlationId = Guid.NewGuid().ToString("N");
|
var correlationId = Guid.NewGuid().ToString("N");
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(new Tenant
|
||||||
|
{
|
||||||
|
Id = tenantId,
|
||||||
|
Slug = tenantId.ToString("N"),
|
||||||
|
Name = "System Scope Tenant"
|
||||||
|
});
|
||||||
await executionScope.ExecuteAsync(
|
await executionScope.ExecuteAsync(
|
||||||
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
|
new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
|
||||||
(_, _) => Task.CompletedTask);
|
(_, _) => Task.CompletedTask);
|
||||||
|
|
||||||
using var verification = factory.CreateSystemScope("Verify execution scope audit");
|
using var verification = factory.CreateSystemScope("Verify execution scope audit");
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ builder.Services.AddOptions<MessagingOptions>()
|
|||||||
!string.IsNullOrWhiteSpace(options.Username) &&
|
!string.IsNullOrWhiteSpace(options.Username) &&
|
||||||
!string.IsNullOrWhiteSpace(options.Password)),
|
!string.IsNullOrWhiteSpace(options.Password)),
|
||||||
"Production RabbitMQ requires a valid Host, Username and Password.")
|
"Production RabbitMQ requires a valid Host, Username and Password.")
|
||||||
|
.Validate(
|
||||||
|
options => options.OutboxBacklogAlertCount > 0 && options.OutboxOldestMessageAlertSeconds > 0,
|
||||||
|
"RabbitMQ outbox alert thresholds must be positive.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
if (messaging.IsConfigured)
|
if (messaging.IsConfigured)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ using Microsoft.Extensions.Options;
|
|||||||
using Tiku.Application.Jobs;
|
using Tiku.Application.Jobs;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
namespace Tiku.Worker;
|
namespace Tiku.Worker;
|
||||||
|
|
||||||
public class Worker(
|
public class Worker(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IOptions<DomainLifecycleOptions> options,
|
IOptions<DomainLifecycleOptions> options,
|
||||||
|
MessagingOptions messagingOptions,
|
||||||
ILogger<Worker> logger) : BackgroundService
|
ILogger<Worker> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||||
@@ -60,6 +62,10 @@ public class Worker(
|
|||||||
.InitializeSystem(null, "Background job lease worker");
|
.InitializeSystem(null, "Background job lease worker");
|
||||||
return await scope.ServiceProvider
|
return await scope.ServiceProvider
|
||||||
.GetRequiredService<IBackgroundJobService>()
|
.GetRequiredService<IBackgroundJobService>()
|
||||||
.ProcessPendingAsync(workerId, 20, stoppingToken);
|
.ProcessPendingAsync(
|
||||||
|
workerId,
|
||||||
|
20,
|
||||||
|
includeImmediateJobs: !messagingOptions.IsConfigured,
|
||||||
|
cancellationToken: stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"Host": "",
|
"Host": "",
|
||||||
"VirtualHost": "/",
|
"VirtualHost": "/",
|
||||||
"Username": "",
|
"Username": "",
|
||||||
"Password": ""
|
"Password": "",
|
||||||
|
"OutboxBacklogAlertCount": 1000,
|
||||||
|
"OutboxOldestMessageAlertSeconds": 300
|
||||||
},
|
},
|
||||||
"TenantDomains": {
|
"TenantDomains": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 认证与授权待补强清单
|
# 认证与授权待补强清单
|
||||||
|
|
||||||
> 2026-07-29 实施状态:可信代理启动校验、外部登录成员生命周期、Redis 跨实例频控与故障关闭、数据库 Capability、事务化 System Scope 审计、MassTransit EF Bus/Consumer Outbox、浏览器 Cookie/CSRF 主链路及 endpoint manifest 已落地。生产网关 ACL、RabbitMQ 4.x 重启/积压演练和按 job type 迁移旧轮询 Worker 仍属于部署验收项。
|
> 2026-07-29 实施状态:可信代理启动校验、外部登录成员生命周期、Redis 跨实例频控与故障关闭、固定目录数据库 Capability、事务化 System Scope 审计、MassTransit EF Bus/Consumer Outbox 与即时 BackgroundJob Consumer、浏览器 Cookie/CSRF 主链路及 endpoint manifest 已落地。本地 RabbitMQ 4.3.4 已验证停机期间事务提交、outbox 积压及重启补发;生产网关 ACL 和生产 Broker 演练仍属于部署验收项。
|
||||||
|
|
||||||
当前生效规则见 [认证、授权与 Host 安全策略](authentication-authorization-security.md)。本文只记录尚需补强的安全事项,不重复描述已实现体系。
|
当前生效规则见 [认证、授权与 Host 安全策略](authentication-authorization-security.md)。本文只记录尚需补强的安全事项,不重复描述已实现体系。
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ Tenant Active
|
|||||||
+ DataScope / Resource Scope
|
+ DataScope / Resource Scope
|
||||||
```
|
```
|
||||||
|
|
||||||
实现说明:`ProductModule`、`PlanModuleEntitlement`、`TenantModuleOverride` 与 `ICapabilityAccessEvaluator` 已进入数据库授权 Handler。模块目录采用渐进启用:只有进入固定目录的模块才强制套餐校验,避免迁移时误封未建档模块。
|
实现说明:固定 `ProductModuleCatalog`、`PlanModuleEntitlement`、可审计 `TenantModuleOverride` 与 `ICapabilityAccessEvaluator` 已进入数据库授权 Handler;未知或缺失目录模块 fail-closed。平台接口可替换套餐模块权益和设置带原因的租户覆盖,变更在同一事务写审计与 outbox 事件。迁移仅为从未配置权益的存量套餐补齐固定目录,已有显式权益不会被扩权。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ Tenant Active
|
|||||||
- 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。
|
- 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。
|
||||||
- 跨租户写操作必须落 `AuditLog`。
|
- 跨租户写操作必须落 `AuditLog`。
|
||||||
|
|
||||||
实现说明:`SystemScopeRequest` 强制 caller、reason、target tenant 和 correlation ID;旧参数签名已移除。成功路径的 entered 审计、跨租户业务写入和 completed 审计处于同一 PostgreSQL 事务,异常路径回滚业务并持久化 entered/failed 审计。
|
实现说明:`SystemScopeRequest` 强制 caller、reason、target tenant 和 correlation ID;没有租户目标时必须显式声明 `IsGlobal`,且 Worker/公共题库不能创建全局 scope。旧参数签名已移除。成功路径的 entered 审计、跨租户业务写入和 completed 审计处于同一 PostgreSQL 事务,异常路径回滚业务并持久化 entered/failed 审计。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ DataScope:
|
|||||||
- `Tiku.Contracts` 只包含版本化 DTO,不引用 EF、HTTP 或 Provider SDK。
|
- `Tiku.Contracts` 只包含版本化 DTO,不引用 EF、HTTP 或 Provider SDK。
|
||||||
- API 使用 MassTransit EF Bus Outbox,Worker consumer 使用 EF inbox/outbox;业务变更、审计和消息由同一 DbContext 提交。
|
- API 使用 MassTransit EF Bus Outbox,Worker consumer 使用 EF inbox/outbox;业务变更、审计和消息由同一 DbContext 提交。
|
||||||
- RabbitMQ 消息只负责非权威失效版本、菜单刷新和下游通知;成员、租户、Session 或套餐失效不等待 consumer。
|
- RabbitMQ 消息只负责非权威失效版本、菜单刷新和下游通知;成员、租户、Session 或套餐失效不等待 consumer。
|
||||||
|
- 即时 `BackgroundJob` 由 `BackgroundJobRequestedV1` Consumer 执行;延时任务和失败后的定时重试继续由数据库调度器处理,同一即时任务不会同时进入两种消费路径。业务 handler 必须使用受审计 System Scope 提供的 scoped `DbContext`。
|
||||||
- System Scope 只能通过完整 `SystemScopeRequest` 创建;成功路径将 entered 审计、跨租户业务写入和 completed 审计放入同一 PostgreSQL 事务。
|
- System Scope 只能通过完整 `SystemScopeRequest` 创建;成功路径将 entered 审计、跨租户业务写入和 completed 审计放入同一 PostgreSQL 事务。
|
||||||
|
|
||||||
## 审计与错误
|
## 审计与错误
|
||||||
@@ -137,6 +138,7 @@ DataScope:
|
|||||||
- CORS 明确 Origin。
|
- CORS 明确 Origin。
|
||||||
- Redis 7.2+ 连接串;Production 缺失时拒绝启动。
|
- Redis 7.2+ 连接串;Production 缺失时拒绝启动。
|
||||||
- RabbitMQ 4.x Host、virtual host 与凭据;Production 缺失时拒绝启动。
|
- RabbitMQ 4.x Host、virtual host 与凭据;Production 缺失时拒绝启动。
|
||||||
|
- 默认镜像不依赖 `x-delayed-message` 插件;Consumer 使用有限即时重试,延时业务重试落回 PostgreSQL `RunAfter`。
|
||||||
- 公网只暴露覆盖 Forwarded Headers 的可信网关,API ACL 只允许该网关访问。
|
- 公网只暴露覆盖 Forwarded Headers 的可信网关,API ACL 只允许该网关访问。
|
||||||
- Secret encryption key。
|
- Secret encryption key。
|
||||||
- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。
|
- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ Controller 授权面由 `AuthorizationManifestTests` 按 HTTP method、route、c
|
|||||||
|
|
||||||
该清单是防止接口绕过评审的变更门禁;实际授权事实仍来自 PostgreSQL permission、Capability 和 DataScope,不能用摘要替代运行时校验。
|
该清单是防止接口绕过评审的变更门禁;实际授权事实仍来自 PostgreSQL permission、Capability 和 DataScope,不能用摘要替代运行时校验。
|
||||||
|
|
||||||
- Action 数量:330
|
- Action 数量:332
|
||||||
- SHA-256:`ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5`
|
- SHA-256:`a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f`
|
||||||
|
|||||||
@@ -11,7 +11,18 @@ export RabbitMq__Username='guest'
|
|||||||
export RabbitMq__Password='guest'
|
export RabbitMq__Password='guest'
|
||||||
```
|
```
|
||||||
|
|
||||||
RabbitMQ 使用 MassTransit 8.5.10 和 PostgreSQL EF Bus/Consumer Outbox。`GET /api/health` 是 liveness,`GET /api/health/ready` 检查 PostgreSQL、已配置的 Redis、RabbitMQ bus health 和 outbox backlog;服务健康不等于认证授权验收完成。
|
RabbitMQ 使用 MassTransit 8.5.10 和 PostgreSQL EF Bus/Consumer Outbox。`GET /api/health` 是 liveness,`GET /api/health/ready` 检查 PostgreSQL、已配置的 Redis、RabbitMQ bus health,并返回 outbox pending、最老消息时长和阈值告警;服务健康不等于认证授权验收完成。
|
||||||
|
官方 RabbitMQ 4.x 镜像无需安装 delayed-message 插件;不要配置 `UseDelayedRedelivery`,延时后台任务由 PostgreSQL `RunAfter` 调度。
|
||||||
|
|
||||||
|
本地 Broker 重启/outbox 恢复演练(仅对明确指定的测试容器执行 stop/start):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TIKU_TEST_RABBITMQ=rabbitmq://localhost \
|
||||||
|
TIKU_TEST_RABBITMQ_RESTART=1 \
|
||||||
|
TIKU_TEST_RABBITMQ_CONTAINER=tiku-rabbitmq \
|
||||||
|
dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj \
|
||||||
|
--filter 'FullyQualifiedName~Bus_outbox_drains_after_real_broker_restart'
|
||||||
|
```
|
||||||
|
|
||||||
这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。
|
这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user