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

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