feat(security): complete capability messaging workflows

This commit is contained in:
2026-07-29 11:21:15 +08:00
parent df88fa19cb
commit 5c4de4b282
35 changed files with 19544 additions and 89 deletions

View File

@@ -51,6 +51,9 @@ public static class DependencyInjection
!string.IsNullOrWhiteSpace(options.Username) &&
!string.IsNullOrWhiteSpace(options.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();
builder.Services.AddSingleton(messaging);
if (messaging.IsConfigured)

View File

@@ -140,6 +140,27 @@ public sealed class UpsertPlatformSubscriptionDto
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 Guid? UserId { get; set; }

View File

@@ -45,6 +45,16 @@ public sealed class HealthController(
var outboxPending = database
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
: -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 response = new
{
@@ -52,7 +62,14 @@ public sealed class HealthController(
database,
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
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
};
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);

View File

@@ -89,6 +89,19 @@ public sealed class PlatformAdminController(
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")]
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
[EndpointSummary("创建或调整租户订阅")]
@@ -100,6 +113,20 @@ public sealed class PlatformAdminController(
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")]
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
[EndpointSummary("查询租户域名状态")]

View File

@@ -72,7 +72,9 @@
"Host": "",
"VirtualHost": "/",
"Username": "",
"Password": ""
"Password": "",
"OutboxBacklogAlertCount": 1000,
"OutboxOldestMessageAlertSeconds": 300
},
"BrowserAuth": {
"AllowedOrigins": []

View File

@@ -33,6 +33,14 @@ public interface IBackgroundJobService
Task<int> ProcessPendingAsync(
string workerId,
int batchSize,
bool includeImmediateJobs = true,
CancellationToken cancellationToken = default);
Task<bool> ProcessRequestedAsync(
Guid jobId,
Guid tenantId,
string jobType,
string workerId,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
@@ -41,3 +49,15 @@ public interface IBackgroundJobService
int limit = 50,
CancellationToken cancellationToken = default);
}
public interface IBackgroundJobDispatcher
{
bool IsEnabled { get; }
Task DispatchAsync(
Guid jobId,
Guid tenantId,
string jobType,
string correlationId,
CancellationToken cancellationToken = default);
}

View File

@@ -110,6 +110,28 @@ public sealed record UpsertPlatformTenantBillingProfileCommand(
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(
Guid TenantId,
string PlanCode,
@@ -239,7 +261,9 @@ public interface IPlatformAdminService
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand 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<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(PlatformAdminActor actor, ReplacePlatformPlanModulesCommand 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<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);

View 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; }
}

View File

@@ -14,7 +14,8 @@ public sealed record SystemScopeRequest(
SystemScopeCallerType CallerType,
string Caller,
string Reason,
string CorrelationId);
string CorrelationId,
bool IsGlobal = false);
public interface ITenantExecutionScope
{

View 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());
}

View File

@@ -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;
}
}

View File

@@ -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,6 +76,48 @@ internal sealed class BackgroundJobService(
foreach (var job in jobs)
{
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(
job.TenantId,
ResolveCapabilityModule(job.JobType),
@@ -75,9 +128,10 @@ internal sealed class BackgroundJobService(
job.CompletedAt = DateTimeOffset.UtcNow;
job.LastError = "Tenant capability was revoked before job execution.";
await dbContext.SaveChangesAsync(cancellationToken);
processed++;
continue;
return true;
}
var now = DateTimeOffset.UtcNow;
job.Status = BackgroundJobStatus.Processing;
job.LockedBy = workerId;
job.LockExpiresAt = now.Add(LeaseDuration);
@@ -104,18 +158,18 @@ internal sealed class BackgroundJobService(
job.Status = job.RetryCount > job.MaxRetries
? BackgroundJobStatus.Failed
: 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
{
job.LockedBy = null;
job.LockExpiresAt = null;
processed++;
await dbContext.SaveChangesAsync(cancellationToken);
}
}
return processed;
return true;
}
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
@@ -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 ||

View 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);
}

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -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>,

File diff suppressed because it is too large Load Diff

View File

@@ -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');
""");
}
}
}

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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

View File

@@ -16,6 +16,8 @@ using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Content;
using Tiku.Domain.Commerce;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.IntegrationTests.Infrastructure;
using Tiku.Infrastructure.Persistence;
@@ -134,6 +136,54 @@ public sealed class ApiTestFactory(
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(null, "Integration test fixture seeding");
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);
await dbContext.SaveChangesAsync();

View File

@@ -7,14 +7,17 @@ using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using MassTransit;
using Tiku.Api.Security;
using Tiku.Application.Security;
using Tiku.Infrastructure.Messaging;
namespace Tiku.IntegrationTests.Api;
public sealed class AuthorizationManifestTests
{
private const int ExpectedActionCount = 330;
private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5";
private const int ExpectedActionCount = 332;
private const string ExpectedSha256 = "a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f";
[Fact]
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)
{
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;

View File

@@ -1,6 +1,10 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -9,6 +13,48 @@ namespace Tiku.IntegrationTests.Api;
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]
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
{
@@ -17,7 +63,6 @@ public sealed class CapabilityAuthorizationTests
await factory.SeedAsync(
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
new ProductModule { Code = "content", Name = "Content" },
new TenantSubscription
{
TenantId = tenantId,

View File

@@ -62,6 +62,9 @@ public sealed class PlatformAdminEndpointTests
var overview = await client.GetAsync("/api/platform-admin/overview");
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(
"/api/platform-admin/subscriptions",
new UpsertPlatformSubscriptionDto
@@ -73,6 +76,13 @@ public sealed class PlatformAdminEndpointTests
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
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 suspended = await client.PatchAsJsonAsync(
"/api/platform-admin/tenants/status",
@@ -90,7 +100,9 @@ public sealed class PlatformAdminEndpointTests
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
Assert.Equal(HttpStatusCode.OK, planModules.StatusCode);
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
Assert.Equal(HttpStatusCode.OK, moduleOverride.StatusCode);
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
@@ -103,6 +115,12 @@ public sealed class PlatformAdminEndpointTests
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
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]

View File

@@ -17,6 +17,7 @@ public sealed class QuestionBankEndpointTests
var otherTenantId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
PlatformTenant(),
Tenant(tenantId, "master"),
Tenant(otherTenantId, "other"),
new QuestionBank
@@ -62,6 +63,7 @@ public sealed class QuestionBankEndpointTests
var versionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
PlatformTenant(),
Tenant(tenantId, "master"),
new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" },
new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" },
@@ -130,6 +132,7 @@ public sealed class QuestionBankEndpointTests
var questionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
PlatformTenant(),
Tenant(tenantId, "master"),
new Question
{
@@ -154,6 +157,7 @@ public sealed class QuestionBankEndpointTests
var questionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
PlatformTenant(),
Tenant(tenantId, "master"),
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)
{
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());

View File

@@ -4,11 +4,16 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StackExchange.Redis;
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text.Json;
using Tiku.Application;
using Tiku.Application.Jobs;
using Tiku.Contracts;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Persistence;
@@ -17,6 +22,199 @@ namespace Tiku.IntegrationTests;
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]
public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version()
{
@@ -177,9 +375,9 @@ public sealed class MassTransitOutboxTests
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)
{
@@ -210,6 +408,10 @@ public sealed class MassTransitOutboxTests
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
using var response = await client.GetAsync($"/api/queues/%2F/{Uri.EscapeDataString(queueName)}");
if (response.StatusCode == HttpStatusCode.NotFound)
{
return 0;
}
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("messages").GetInt32();

View File

@@ -16,10 +16,20 @@ public sealed class SystemScopeAuditTests
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"),
(_, _) => 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 tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "System Scope Tenant"
});
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);
using var verification = factory.CreateSystemScope("Verify execution scope audit");

View File

@@ -37,6 +37,9 @@ builder.Services.AddOptions<MessagingOptions>()
!string.IsNullOrWhiteSpace(options.Username) &&
!string.IsNullOrWhiteSpace(options.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();
if (messaging.IsConfigured)
{

View File

@@ -2,12 +2,14 @@ using Microsoft.Extensions.Options;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Messaging;
namespace Tiku.Worker;
public class Worker(
IServiceScopeFactory scopeFactory,
IOptions<DomainLifecycleOptions> options,
MessagingOptions messagingOptions,
ILogger<Worker> logger) : BackgroundService
{
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
@@ -60,6 +62,10 @@ public class Worker(
.InitializeSystem(null, "Background job lease worker");
return await scope.ServiceProvider
.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync(workerId, 20, stoppingToken);
.ProcessPendingAsync(
workerId,
20,
includeImmediateJobs: !messagingOptions.IsConfigured,
cancellationToken: stoppingToken);
}
}

View File

@@ -3,7 +3,9 @@
"Host": "",
"VirtualHost": "/",
"Username": "",
"Password": ""
"Password": "",
"OutboxBacklogAlertCount": 1000,
"OutboxOldestMessageAlertSeconds": 300
},
"TenantDomains": {
"Enabled": true,

View File

@@ -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)。本文只记录尚需补强的安全事项,不重复描述已实现体系。
@@ -49,7 +49,7 @@ Tenant Active
+ 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。
- 跨租户写操作必须落 `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 审计。
验收:

View File

@@ -110,6 +110,7 @@ DataScope
- `Tiku.Contracts` 只包含版本化 DTO不引用 EF、HTTP 或 Provider SDK。
- API 使用 MassTransit EF Bus OutboxWorker consumer 使用 EF inbox/outbox业务变更、审计和消息由同一 DbContext 提交。
- RabbitMQ 消息只负责非权威失效版本、菜单刷新和下游通知成员、租户、Session 或套餐失效不等待 consumer。
- 即时 `BackgroundJob``BackgroundJobRequestedV1` Consumer 执行;延时任务和失败后的定时重试继续由数据库调度器处理,同一即时任务不会同时进入两种消费路径。业务 handler 必须使用受审计 System Scope 提供的 scoped `DbContext`
- System Scope 只能通过完整 `SystemScopeRequest` 创建;成功路径将 entered 审计、跨租户业务写入和 completed 审计放入同一 PostgreSQL 事务。
## 审计与错误
@@ -137,6 +138,7 @@ DataScope
- CORS 明确 Origin。
- Redis 7.2+ 连接串Production 缺失时拒绝启动。
- RabbitMQ 4.x Host、virtual host 与凭据Production 缺失时拒绝启动。
- 默认镜像不依赖 `x-delayed-message` 插件Consumer 使用有限即时重试,延时业务重试落回 PostgreSQL `RunAfter`
- 公网只暴露覆盖 Forwarded Headers 的可信网关API ACL 只允许该网关访问。
- Secret encryption key。
- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。

View File

@@ -5,5 +5,5 @@ Controller 授权面由 `AuthorizationManifestTests` 按 HTTP method、route、c
该清单是防止接口绕过评审的变更门禁;实际授权事实仍来自 PostgreSQL permission、Capability 和 DataScope不能用摘要替代运行时校验。
- Action 数量330
- SHA-256`ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5`
- Action 数量332
- SHA-256`a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f`

View File

@@ -11,7 +11,18 @@ export RabbitMq__Username='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并完成平台管理员的首次登录。