forked from gongxuegit/tiku-backend.net
feat(security): add distributed authorization foundation
This commit is contained in:
@@ -26,6 +26,11 @@
|
|||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="StackExchange.Redis" Version="3.0.17" />
|
||||||
|
<PackageVersion Include="MassTransit" Version="8.5.10" />
|
||||||
|
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.10" />
|
||||||
|
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.78.0" />
|
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.78.0" />
|
||||||
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="Tiku.Api/Tiku.Api.csproj" />
|
<Project Path="Tiku.Api/Tiku.Api.csproj" />
|
||||||
<Project Path="Tiku.Application/Tiku.Application.csproj" />
|
<Project Path="Tiku.Application/Tiku.Application.csproj" />
|
||||||
|
<Project Path="Tiku.Contracts/Tiku.Contracts.csproj" />
|
||||||
<Project Path="Tiku.DbMigrator/Tiku.DbMigrator.csproj" />
|
<Project Path="Tiku.DbMigrator/Tiku.DbMigrator.csproj" />
|
||||||
<Project Path="Tiku.Domain/Tiku.Domain.csproj" />
|
<Project Path="Tiku.Domain/Tiku.Domain.csproj" />
|
||||||
<Project Path="Tiku.Infrastructure/Tiku.Infrastructure.csproj" />
|
<Project Path="Tiku.Infrastructure/Tiku.Infrastructure.csproj" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Tiku.Api.OpenApi;
|
using Tiku.Api.OpenApi;
|
||||||
|
using Tiku.Api.Security;
|
||||||
|
|
||||||
namespace Tiku.Api.Configuration;
|
namespace Tiku.Api.Configuration;
|
||||||
|
|
||||||
@@ -7,7 +8,8 @@ internal static class ApiPresentationExtensions
|
|||||||
{
|
{
|
||||||
internal static IServiceCollection AddApiPresentation(this IServiceCollection services)
|
internal static IServiceCollection AddApiPresentation(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddControllers()
|
services.AddControllers(options =>
|
||||||
|
options.Conventions.Add(new EndpointAuthorizationMetadataConvention()))
|
||||||
.AddJsonOptions(options =>
|
.AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public static class ApplicationBuilderExtensions
|
|||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseCors(CorsOptions.PolicyName);
|
app.UseCors(CorsOptions.PolicyName);
|
||||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||||
|
app.UseMiddleware<BrowserCsrfMiddleware>();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
|
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ internal static class AuthenticationExtensions
|
|||||||
var jwtOptions = configuration
|
var jwtOptions = configuration
|
||||||
.GetSection("Security:Jwt")
|
.GetSection("Security:Jwt")
|
||||||
.Get<JwtOptions>() ?? new JwtOptions();
|
.Get<JwtOptions>() ?? new JwtOptions();
|
||||||
|
services.AddOptions<BrowserAuthOptions>()
|
||||||
|
.Bind(configuration.GetSection(BrowserAuthOptions.SectionName))
|
||||||
|
.Validate(
|
||||||
|
options => options.AllowedOrigins.All(origin =>
|
||||||
|
Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
|
||||||
|
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) &&
|
||||||
|
string.IsNullOrEmpty(uri.PathAndQuery.Trim('/'))),
|
||||||
|
"BrowserAuth AllowedOrigins must contain only HTTP(S) origins without paths.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
services
|
services
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
@@ -84,11 +93,41 @@ internal static class AuthenticationExtensions
|
|||||||
};
|
};
|
||||||
options.Events = new JwtBearerEvents
|
options.Events = new JwtBearerEvents
|
||||||
{
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(context.Request.Headers.Authorization) &&
|
||||||
|
IsSameOriginBrowserRequest(context.Request) &&
|
||||||
|
context.Request.Cookies.TryGetValue(BrowserAuthOptions.AccessCookie, out var accessToken))
|
||||||
|
{
|
||||||
|
context.Token = accessToken;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
OnTokenValidated = ValidateTokenAsync,
|
OnTokenValidated = ValidateTokenAsync,
|
||||||
OnChallenge = WriteTenantConflictChallengeAsync
|
OnChallenge = WriteTenantConflictChallengeAsync
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsSameOriginBrowserRequest(HttpRequest request)
|
||||||
|
{
|
||||||
|
var source = request.Headers.Origin.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(source))
|
||||||
|
{
|
||||||
|
source = request.Headers.Referer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Uri.TryCreate(source, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
request.Headers["Sec-Fetch-Site"].ToString(),
|
||||||
|
"same-origin",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task ValidateTokenAsync(TokenValidatedContext context)
|
private static async Task ValidateTokenAsync(TokenValidatedContext context)
|
||||||
{
|
{
|
||||||
var principal = context.Principal;
|
var principal = context.Principal;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Tiku.Api.Configuration;
|
namespace Tiku.Api.Configuration;
|
||||||
|
|
||||||
@@ -15,14 +17,51 @@ public static class DependencyInjection
|
|||||||
preserveStaticLogger: true);
|
preserveStaticLogger: true);
|
||||||
|
|
||||||
builder.Services.AddApiPresentation();
|
builder.Services.AddApiPresentation();
|
||||||
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddNetworkConfiguration(builder.Configuration);
|
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
|
||||||
builder.Services.AddApiRateLimiting(builder.Configuration);
|
builder.Services.AddApiRateLimiting(builder.Configuration);
|
||||||
|
|
||||||
var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString(
|
var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString(
|
||||||
builder.Configuration,
|
builder.Configuration,
|
||||||
builder.Environment.IsDevelopment());
|
builder.Environment.IsDevelopment());
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
builder.Services.AddInfrastructure(connectionString);
|
||||||
|
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
|
||||||
|
builder.Services.AddOptions<RedisSecurityConnectionOptions>()
|
||||||
|
.Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty)
|
||||||
|
.Validate(
|
||||||
|
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
|
||||||
|
"Redis is required in Production.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
if (!string.IsNullOrWhiteSpace(redisConnectionString))
|
||||||
|
{
|
||||||
|
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
|
||||||
|
}
|
||||||
|
else if (builder.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
|
||||||
|
}
|
||||||
|
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
|
||||||
|
builder.Services.AddOptions<MessagingOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection("RabbitMq"))
|
||||||
|
.Validate(
|
||||||
|
options => !builder.Environment.IsProduction() ||
|
||||||
|
(options.IsConfigured &&
|
||||||
|
!string.IsNullOrWhiteSpace(options.Username) &&
|
||||||
|
!string.IsNullOrWhiteSpace(options.Password)),
|
||||||
|
"Production RabbitMQ requires a valid Host, Username and Password.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
builder.Services.AddSingleton(messaging);
|
||||||
|
if (messaging.IsConfigured)
|
||||||
|
{
|
||||||
|
messaging.ConfigureConsumers = false;
|
||||||
|
builder.Services.AddReliableMessaging(messaging);
|
||||||
|
}
|
||||||
|
else if (builder.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host.");
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
|
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
|
||||||
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
|
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ internal static class NetworkConfigurationExtensions
|
|||||||
{
|
{
|
||||||
internal static IServiceCollection AddNetworkConfiguration(
|
internal static IServiceCollection AddNetworkConfiguration(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration,
|
||||||
|
IHostEnvironment environment)
|
||||||
{
|
{
|
||||||
services.AddOptions<TenantResolutionOptions>()
|
services.AddOptions<TenantResolutionOptions>()
|
||||||
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName));
|
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName))
|
||||||
|
.Validate(
|
||||||
|
options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration, environment.IsProduction()),
|
||||||
|
"Production requires formal platform hosts, non-wildcard AllowedHosts, and trusted proxy addresses.")
|
||||||
|
.ValidateOnStart();
|
||||||
services.Configure<ForwardedHeadersOptions>(options =>
|
services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders =
|
options.ForwardedHeaders =
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed class AuthController(
|
|||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||||
[HttpPost("sms/send")]
|
[HttpPost("sms/send")]
|
||||||
|
[EndpointSummary("发送短信验证码")]
|
||||||
|
[EndpointDescription("发送登录用途短信验证码,并应用租户级短信限流。")]
|
||||||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
|
||||||
@@ -86,6 +88,7 @@ public sealed class AuthController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||||
[HttpPost("login/sms")]
|
[HttpPost("login/sms")]
|
||||||
[EndpointSummary("短信验证码登录")]
|
[EndpointSummary("短信验证码登录")]
|
||||||
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
|
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
|
||||||
@@ -195,6 +198,8 @@ public sealed class AuthController(
|
|||||||
|
|
||||||
[HttpPost("logout-all")]
|
[HttpPost("logout-all")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[EndpointSummary("退出全部登录会话")]
|
||||||
|
[EndpointDescription("撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -210,6 +215,8 @@ public sealed class AuthController(
|
|||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("password/change-required")]
|
[HttpPost("password/change-required")]
|
||||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||||
|
[EndpointSummary("修改首次登录必改密码")]
|
||||||
|
[EndpointDescription("校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。")]
|
||||||
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
|
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
|
||||||
[FromBody] RequiredPasswordChangeDto request,
|
[FromBody] RequiredPasswordChangeDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class BackgroundJobsController(
|
|||||||
ITenantContext tenantContext) : ControllerBase
|
ITenantContext tenantContext) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[EndpointSummary("查询租户后台任务")]
|
||||||
[ProducesResponseType<IReadOnlyCollection<BackgroundJobItem>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<IReadOnlyCollection<BackgroundJobItem>>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> List(
|
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> List(
|
||||||
[FromQuery] string? jobType,
|
[FromQuery] string? jobType,
|
||||||
@@ -25,6 +26,7 @@ public sealed class BackgroundJobsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[EndpointSummary("创建租户后台任务")]
|
||||||
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackgroundJobItem>> Create(
|
public async Task<ActionResult<BackgroundJobItem>> Create(
|
||||||
CreateBackgroundJobDto request,
|
CreateBackgroundJobDto request,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public sealed class BackofficeController(
|
|||||||
{
|
{
|
||||||
[HttpGet("tenant/ui-bootstrap")]
|
[HttpGet("tenant/ui-bootstrap")]
|
||||||
[Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
|
[Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
|
||||||
|
[EndpointSummary("查询租户后台菜单与权限")]
|
||||||
|
[EndpointDescription("返回当前租户管理员可见的后台菜单、权限和模块启用状态。")]
|
||||||
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeUiBootstrap>> GetTenantUiBootstrap(CancellationToken cancellationToken)
|
public async Task<ActionResult<BackofficeUiBootstrap>> GetTenantUiBootstrap(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpGet("tenant/bootstrap")]
|
[HttpGet("tenant/bootstrap")]
|
||||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
|
[EndpointSummary("查询租户角色管理初始化数据")]
|
||||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
|
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -32,6 +35,8 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpGet("platform/ui-bootstrap")]
|
[HttpGet("platform/ui-bootstrap")]
|
||||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||||
|
[EndpointSummary("查询平台后台菜单与权限")]
|
||||||
|
[EndpointDescription("返回当前平台管理员可见的后台菜单、权限和模块启用状态。")]
|
||||||
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeUiBootstrap>> GetPlatformUiBootstrap(CancellationToken cancellationToken)
|
public async Task<ActionResult<BackofficeUiBootstrap>> GetPlatformUiBootstrap(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -42,6 +47,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpPost("tenant/roles")]
|
[HttpPost("tenant/roles")]
|
||||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
|
[EndpointSummary("创建或更新租户后台角色")]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
|
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
|
||||||
UpsertBackofficeRoleDto request,
|
UpsertBackofficeRoleDto request,
|
||||||
@@ -52,6 +58,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
|
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
|
||||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
|
[EndpointSummary("替换租户后台角色权限绑定")]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
|
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
|
||||||
Guid roleId,
|
Guid roleId,
|
||||||
@@ -63,6 +70,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpPut("tenant/users/{userId:guid}/roles")]
|
[HttpPut("tenant/users/{userId:guid}/roles")]
|
||||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||||
|
[EndpointSummary("替换租户用户后台角色")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public async Task<IActionResult> ReplaceTenantUserRoles(
|
public async Task<IActionResult> ReplaceTenantUserRoles(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
@@ -75,6 +83,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpGet("platform/bootstrap")]
|
[HttpGet("platform/bootstrap")]
|
||||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
|
[EndpointSummary("查询平台角色管理初始化数据")]
|
||||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
|
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -83,6 +92,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpPost("platform/roles")]
|
[HttpPost("platform/roles")]
|
||||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
|
[EndpointSummary("创建或更新平台后台角色")]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
|
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
|
||||||
UpsertBackofficeRoleDto request,
|
UpsertBackofficeRoleDto request,
|
||||||
@@ -93,6 +103,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpPut("platform/roles/{roleId:guid}/bindings")]
|
[HttpPut("platform/roles/{roleId:guid}/bindings")]
|
||||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
|
[EndpointSummary("替换平台后台角色权限绑定")]
|
||||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
|
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
|
||||||
Guid roleId,
|
Guid roleId,
|
||||||
@@ -104,6 +115,7 @@ public sealed class BackofficeController(
|
|||||||
|
|
||||||
[HttpPut("platform/users/{userId:guid}/roles")]
|
[HttpPut("platform/users/{userId:guid}/roles")]
|
||||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||||
|
[EndpointSummary("替换平台用户后台角色")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public async Task<IActionResult> ReplacePlatformUserRoles(
|
public async Task<IActionResult> ReplacePlatformUserRoles(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
|
|||||||
251
Tiku.Api/Controllers/BrowserAuthController.cs
Normal file
251
Tiku.Api/Controllers/BrowserAuthController.cs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Api.Options;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Application.Tenancy;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Content;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/browser-auth")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class BrowserAuthController(
|
||||||
|
IAuthService authService,
|
||||||
|
ISmsVerificationService smsVerificationService,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ITenantContextInitializer tenantContextInitializer,
|
||||||
|
ITenantDirectory tenantDirectory,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||||||
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||||
|
[HttpPost("sms/send")]
|
||||||
|
public async Task<ActionResult<SmsSendResult>> SendSmsCode(
|
||||||
|
[FromBody] SendSmsCodeDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureTrustedOrigin();
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
|
if (realm != AuthRealm.Tenant)
|
||||||
|
{
|
||||||
|
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||||||
|
}
|
||||||
|
var tenantId = await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken)
|
||||||
|
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||||||
|
var result = await smsVerificationService.CreateCodeAsync(new SendSmsCodeRequest(
|
||||||
|
tenantId,
|
||||||
|
request.Phone,
|
||||||
|
SmsPurpose.Login,
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Request.Headers.UserAgent.ToString(),
|
||||||
|
request.DeviceId), cancellationToken);
|
||||||
|
return Accepted(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("login/password")]
|
||||||
|
public async Task<ActionResult<object>> LoginWithPassword(
|
||||||
|
[FromBody] PasswordLoginDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureTrustedOrigin();
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
|
var identifier = request.Identifier ?? request.Phone;
|
||||||
|
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
|
||||||
|
var result = await authService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||||
|
realm,
|
||||||
|
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
|
identifier,
|
||||||
|
request.Password,
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||||
|
return Ok(WriteResult(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||||
|
[HttpPost("login/sms")]
|
||||||
|
public async Task<ActionResult<object>> LoginWithSms(
|
||||||
|
[FromBody] SmsLoginDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureTrustedOrigin();
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
|
var result = await authService.LoginWithSmsAsync(new SmsLoginRequest(
|
||||||
|
realm,
|
||||||
|
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
|
request.Phone,
|
||||||
|
request.Code,
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||||
|
return Ok(WriteResult(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("oauth/wechat")]
|
||||||
|
public async Task<ActionResult<object>> LoginWithWechatWeb(
|
||||||
|
[FromBody] OAuthCodeDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureTrustedOrigin();
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
|
var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest(
|
||||||
|
realm,
|
||||||
|
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
|
request.Code,
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||||
|
return Ok(WriteResult(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("oauth/wechat-miniapp")]
|
||||||
|
public async Task<ActionResult<object>> LoginWithWechatMiniApp(
|
||||||
|
[FromBody] OAuthCodeDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureTrustedOrigin();
|
||||||
|
var realm = request.Realm!.Value;
|
||||||
|
var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||||
|
realm,
|
||||||
|
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||||
|
request.Code,
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||||
|
return Ok(WriteResult(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<ActionResult<object>> Refresh(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
|
||||||
|
if (string.IsNullOrWhiteSpace(refreshToken)) return Unauthorized();
|
||||||
|
var tokens = await authService.RefreshAsync(new RefreshSessionRequest(
|
||||||
|
refreshToken,
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||||
|
WriteCookies(tokens);
|
||||||
|
return Ok(new { status = "authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
|
||||||
|
if (!string.IsNullOrWhiteSpace(refreshToken))
|
||||||
|
{
|
||||||
|
await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken);
|
||||||
|
}
|
||||||
|
ClearCookies();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("logout-all")]
|
||||||
|
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
await authService.LogoutAllAsync(userId, cancellationToken);
|
||||||
|
ClearCookies();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private object WriteResult(AuthenticationResult result)
|
||||||
|
{
|
||||||
|
if (result.User?.Tokens is { } tokens)
|
||||||
|
{
|
||||||
|
WriteCookies(tokens);
|
||||||
|
}
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
status = result.Status.ToString(),
|
||||||
|
user = result.User is null ? null : new
|
||||||
|
{
|
||||||
|
result.User.UserId,
|
||||||
|
result.User.Phone,
|
||||||
|
result.User.Email,
|
||||||
|
result.User.Name,
|
||||||
|
result.User.Realm,
|
||||||
|
result.User.Tenant
|
||||||
|
},
|
||||||
|
result.ChallengeToken,
|
||||||
|
result.ChallengeExpiresAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteCookies(AuthTokenPair tokens)
|
||||||
|
{
|
||||||
|
Response.Cookies.Append(BrowserAuthOptions.AccessCookie, tokens.AccessToken, new CookieOptions
|
||||||
|
{
|
||||||
|
Secure = true,
|
||||||
|
HttpOnly = true,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Path = "/",
|
||||||
|
MaxAge = TimeSpan.FromMinutes(15)
|
||||||
|
});
|
||||||
|
Response.Cookies.Append(BrowserAuthOptions.RefreshCookie, tokens.RefreshToken, new CookieOptions
|
||||||
|
{
|
||||||
|
Secure = true,
|
||||||
|
HttpOnly = true,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Path = "/api/browser-auth",
|
||||||
|
MaxAge = TimeSpan.FromDays(30)
|
||||||
|
});
|
||||||
|
Response.Cookies.Append(BrowserAuthOptions.CsrfCookie,
|
||||||
|
Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(), new CookieOptions
|
||||||
|
{
|
||||||
|
Secure = true,
|
||||||
|
HttpOnly = false,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Path = "/"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearCookies()
|
||||||
|
{
|
||||||
|
Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||||
|
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/browser-auth" });
|
||||||
|
Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTrustedOrigin()
|
||||||
|
{
|
||||||
|
var origin = Request.Headers.Origin.ToString();
|
||||||
|
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
|
||||||
|
!string.Equals(uri.Scheme, Request.Scheme, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!string.Equals(uri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new BrowserOriginException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid?> ResolveTenantIdAsync(AuthRealm realm, string? tenantCode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (realm == AuthRealm.Platform)
|
||||||
|
{
|
||||||
|
var requestHost = Request.Host.Host.Trim().TrimEnd('.');
|
||||||
|
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
|
||||||
|
string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (tenantContext.TenantId is { } resolved) return resolved;
|
||||||
|
if (string.IsNullOrWhiteSpace(tenantCode)) throw new RequiredFieldException("tenantCode is required.");
|
||||||
|
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||||
|
?? throw new TenantNotFoundException();
|
||||||
|
tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||||
|
return tenant.TenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");
|
||||||
@@ -16,50 +16,62 @@ public sealed class CommissionController(
|
|||||||
ITenantContext currentTenant) : ControllerBase
|
ITenantContext currentTenant) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
|
[EndpointSummary("查询佣金配置")]
|
||||||
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken));
|
Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken));
|
||||||
|
|
||||||
[HttpPut("settings")]
|
[HttpPut("settings")]
|
||||||
|
[EndpointSummary("保存佣金配置")]
|
||||||
public async Task<ActionResult<CommissionSettingsItem>> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionSettingsItem>> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
|
||||||
[HttpPut("member-rate")]
|
[HttpPut("member-rate")]
|
||||||
|
[EndpointSummary("调整成员佣金比例")]
|
||||||
public async Task<ActionResult<object>> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<object>> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
|
||||||
[HttpGet("summary")]
|
[HttpGet("summary")]
|
||||||
|
[EndpointSummary("查询佣金统计摘要")]
|
||||||
public async Task<ActionResult<CommissionSummaryItem>> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionSummaryItem>> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||||
|
|
||||||
[HttpGet("orders")]
|
[HttpGet("orders")]
|
||||||
|
[EndpointSummary("查询佣金来源订单")]
|
||||||
public async Task<ActionResult<CommissionList<CommissionSourceItem>>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionList<CommissionSourceItem>>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||||
|
|
||||||
[HttpGet("settlements")]
|
[HttpGet("settlements")]
|
||||||
|
[EndpointSummary("查询佣金结算单")]
|
||||||
public async Task<ActionResult<CommissionList<CommissionSettlementItemDto>>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionList<CommissionSettlementItemDto>>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||||
|
|
||||||
[HttpGet("settlements/export")]
|
[HttpGet("settlements/export")]
|
||||||
|
[EndpointSummary("导出佣金结算单")]
|
||||||
public async Task<ActionResult<CommissionExportItem>> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionExportItem>> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken));
|
Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("settlements/generate")]
|
[HttpPost("settlements/generate")]
|
||||||
|
[EndpointSummary("生成佣金结算单")]
|
||||||
public async Task<ActionResult<CommissionSettlementItemDto>> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionSettlementItemDto>> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
|
||||||
[HttpPost("settlements/status")]
|
[HttpPost("settlements/status")]
|
||||||
|
[EndpointSummary("更新佣金结算单状态")]
|
||||||
public async Task<ActionResult<CommissionSettlementItemDto>> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionSettlementItemDto>> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
|
||||||
[HttpGet("settlements/proofs")]
|
[HttpGet("settlements/proofs")]
|
||||||
|
[EndpointSummary("查询佣金结算凭证")]
|
||||||
public async Task<ActionResult<CommissionList<CommissionProofItem>>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionList<CommissionProofItem>>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken));
|
Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("settlements/proofs")]
|
[HttpPost("settlements/proofs")]
|
||||||
|
[EndpointSummary("创建佣金结算凭证")]
|
||||||
public async Task<ActionResult<CommissionProofItem>> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionProofItem>> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
|
||||||
[HttpPost("settlements/proofs/status")]
|
[HttpPost("settlements/proofs/status")]
|
||||||
|
[EndpointSummary("更新佣金结算凭证状态")]
|
||||||
public async Task<ActionResult<CommissionProofItem>> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<CommissionProofItem>> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) =>
|
||||||
Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Tiku.Api.Contracts;
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MassTransit.EntityFrameworkCoreIntegration;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
namespace Tiku.Api.Controllers;
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
@@ -8,7 +14,11 @@ namespace Tiku.Api.Controllers;
|
|||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/health")]
|
[Route("api/health")]
|
||||||
public sealed class HealthController : ControllerBase
|
public sealed class HealthController(
|
||||||
|
TikuDbContext dbContext,
|
||||||
|
IRedisSecurityStore redisSecurityStore,
|
||||||
|
MessagingOptions messagingOptions,
|
||||||
|
HealthCheckService healthCheckService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[EndpointSummary("健康检查")]
|
[EndpointSummary("健康检查")]
|
||||||
@@ -21,4 +31,30 @@ public sealed class HealthController : ControllerBase
|
|||||||
"tiku-api",
|
"tiku-api",
|
||||||
DateTimeOffset.UtcNow));
|
DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("ready")]
|
||||||
|
[EndpointSummary("依赖就绪检查")]
|
||||||
|
public async Task<ActionResult<object>> Ready(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||||
|
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||||
|
var rabbitHealth = await healthCheckService.CheckHealthAsync(
|
||||||
|
registration => registration.Tags.Contains("ready"),
|
||||||
|
cancellationToken);
|
||||||
|
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
|
||||||
|
var outboxPending = database
|
||||||
|
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
|
||||||
|
: -1;
|
||||||
|
var ready = database && redis && rabbitMq;
|
||||||
|
var response = new
|
||||||
|
{
|
||||||
|
status = ready ? "ready" : "not_ready",
|
||||||
|
database,
|
||||||
|
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||||
|
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
|
||||||
|
outbox = new { pending = outboxPending },
|
||||||
|
checkedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public sealed class MeController(
|
|||||||
TikuDbContext dbContext) : ControllerBase
|
TikuDbContext dbContext) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[EndpointSummary("获取当前登录用户")]
|
||||||
|
[EndpointDescription("根据 Bearer Token 返回当前用户基础信息和活跃租户成员摘要。")]
|
||||||
public async Task<ActionResult<MeResponse>> Get(CancellationToken cancellationToken)
|
public async Task<ActionResult<MeResponse>> Get(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is null)
|
if (currentUser.UserId is null)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed class SecurityDiagnosticsController(
|
|||||||
{
|
{
|
||||||
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
|
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
|
||||||
[HttpGet("authenticated")]
|
[HttpGet("authenticated")]
|
||||||
|
[EndpointSummary("诊断已登录用户上下文")]
|
||||||
public ActionResult<object> Authenticated()
|
public ActionResult<object> Authenticated()
|
||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -24,6 +25,7 @@ public sealed class SecurityDiagnosticsController(
|
|||||||
|
|
||||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||||
[HttpGet("tenant-member")]
|
[HttpGet("tenant-member")]
|
||||||
|
[EndpointSummary("诊断当前租户成员上下文")]
|
||||||
public ActionResult<object> TenantMember()
|
public ActionResult<object> TenantMember()
|
||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -34,6 +36,7 @@ public sealed class SecurityDiagnosticsController(
|
|||||||
|
|
||||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
[HttpGet("tenant-admin")]
|
[HttpGet("tenant-admin")]
|
||||||
|
[EndpointSummary("诊断当前租户管理员上下文")]
|
||||||
public ActionResult<object> TenantAdmin()
|
public ActionResult<object> TenantAdmin()
|
||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public sealed class TaxonomyController(
|
|||||||
ITaxonomyService taxonomyService) : ControllerBase
|
ITaxonomyService taxonomyService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[EndpointSummary("查询租户分类节点")]
|
||||||
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
|
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
|
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
|
||||||
@@ -22,6 +23,7 @@ public sealed class TaxonomyController(
|
|||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||||
|
[EndpointSummary("创建租户分类节点")]
|
||||||
public Task<TaxonomyNodeItem> Create(
|
public Task<TaxonomyNodeItem> Create(
|
||||||
CreateTaxonomyNodeDto request,
|
CreateTaxonomyNodeDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ public sealed class TenantFrontendConfigController(
|
|||||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[EndpointSummary("查询租户前端配置")]
|
||||||
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
|
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
|
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("draft")]
|
[HttpPut("draft")]
|
||||||
|
[EndpointSummary("保存租户前端配置草稿")]
|
||||||
public Task<TenantFrontendConfigItem> SaveDraft(
|
public Task<TenantFrontendConfigItem> SaveDraft(
|
||||||
SaveTenantFrontendConfigDraftDto request,
|
SaveTenantFrontendConfigDraftDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -32,6 +34,7 @@ public sealed class TenantFrontendConfigController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("publish")]
|
[HttpPost("publish")]
|
||||||
|
[EndpointSummary("发布租户前端配置")]
|
||||||
public Task<TenantFrontendConfigItem> Publish(
|
public Task<TenantFrontendConfigItem> Publish(
|
||||||
PublishTenantFrontendConfigDto request,
|
PublishTenantFrontendConfigDto request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class TenantsController(
|
|||||||
TikuDbContext dbContext) : ControllerBase
|
TikuDbContext dbContext) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("current")]
|
[HttpGet("current")]
|
||||||
|
[EndpointSummary("查询当前租户")]
|
||||||
|
[EndpointDescription("返回当前请求租户及当前用户在该租户内的成员角色。")]
|
||||||
public async Task<ActionResult<CurrentTenantResponse>> GetCurrent(CancellationToken cancellationToken)
|
public async Task<ActionResult<CurrentTenantResponse>> GetCurrent(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is null || currentTenant.TenantId is null)
|
if (currentUser.UserId is null || currentTenant.TenantId is null)
|
||||||
|
|||||||
@@ -3,11 +3,24 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Tiku.Api.Options;
|
using Tiku.Api.Options;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Tiku.Api.Middleware;
|
namespace Tiku.Api.Middleware;
|
||||||
|
|
||||||
public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
|
public sealed class AuthRateLimitPartitionMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
IRedisSecurityStore redisSecurityStore,
|
||||||
|
Microsoft.Extensions.Options.IOptions<AuthRateLimitOptions> options)
|
||||||
{
|
{
|
||||||
|
public AuthRateLimitPartitionMiddleware(RequestDelegate next)
|
||||||
|
: this(
|
||||||
|
next,
|
||||||
|
new NullRedisSecurityStore(),
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new AuthRateLimitOptions()))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var policy = context.GetEndpoint()?
|
var policy = context.GetEndpoint()?
|
||||||
@@ -24,11 +37,70 @@ public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
|
|||||||
if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
|
if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
|
||||||
{
|
{
|
||||||
await CaptureAccountHashAsync(context, propertyName);
|
await CaptureAccountHashAsync(context, propertyName);
|
||||||
|
if (redisSecurityStore.IsConfigured)
|
||||||
|
{
|
||||||
|
await ConsumeDistributedLimitAsync(context, policy!);
|
||||||
|
if (context.Response.HasStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await next(context);
|
await next(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ConsumeDistributedLimitAsync(HttpContext context, string policyName)
|
||||||
|
{
|
||||||
|
var ip = Hash(context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip");
|
||||||
|
var account = context.Items.TryGetValue(AuthRateLimitPartitionKey.AccountHashItemKey, out var value)
|
||||||
|
? value as string ?? "unknown-account"
|
||||||
|
: "unknown-account";
|
||||||
|
var isPassword = policyName == AuthRateLimitPolicies.Password;
|
||||||
|
var limit = isPassword ? options.Value.PasswordPermitLimit : options.Value.SmsPermitLimit;
|
||||||
|
var window = TimeSpan.FromSeconds(isPassword
|
||||||
|
? options.Value.PasswordWindowSeconds
|
||||||
|
: options.Value.SmsWindowSeconds);
|
||||||
|
|
||||||
|
DistributedRateLimitResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await redisSecurityStore.ConsumeAsync(
|
||||||
|
[
|
||||||
|
new DistributedRateLimitBucket($"{policyName}:ip:{ip}", limit * 4, window),
|
||||||
|
new DistributedRateLimitBucket($"{policyName}:ip-account:{ip}:{account}", limit, window)
|
||||||
|
], context.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (RedisSecurityUnavailableException)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
title = "Authentication security service is unavailable.",
|
||||||
|
status = StatusCodes.Status503ServiceUnavailable,
|
||||||
|
code = "auth_security_unavailable",
|
||||||
|
traceId = context.TraceIdentifier
|
||||||
|
}, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Allowed)
|
||||||
|
{
|
||||||
|
if (result.RetryAfter is { } retryAfter)
|
||||||
|
{
|
||||||
|
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||||
|
}
|
||||||
|
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
title = "Too many requests.",
|
||||||
|
status = StatusCodes.Status429TooManyRequests,
|
||||||
|
code = "rate_limited",
|
||||||
|
traceId = context.TraceIdentifier
|
||||||
|
}, context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName)
|
private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName)
|
||||||
{
|
{
|
||||||
context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384);
|
context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384);
|
||||||
|
|||||||
68
Tiku.Api/Middleware/BrowserCsrfMiddleware.cs
Normal file
68
Tiku.Api/Middleware/BrowserCsrfMiddleware.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tiku.Api.Options;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Middleware;
|
||||||
|
|
||||||
|
public sealed class BrowserCsrfMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
IOptions<BrowserAuthOptions> options)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (!IsUnsafe(context.Request.Method) || !IsBrowserCookieRequest(context.Request))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsTrustedOrigin(context.Request) || !HasValidCsrfToken(context.Request))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
title = "Browser request origin or CSRF token is invalid.",
|
||||||
|
status = StatusCodes.Status403Forbidden,
|
||||||
|
code = "browser_csrf_rejected",
|
||||||
|
traceId = context.TraceIdentifier
|
||||||
|
}, context.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnsafe(string method) =>
|
||||||
|
!HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsOptions(method);
|
||||||
|
|
||||||
|
private static bool IsBrowserCookieRequest(HttpRequest request) =>
|
||||||
|
request.Cookies.ContainsKey(BrowserAuthOptions.AccessCookie) ||
|
||||||
|
request.Cookies.ContainsKey(BrowserAuthOptions.RefreshCookie);
|
||||||
|
|
||||||
|
private bool IsTrustedOrigin(HttpRequest request)
|
||||||
|
{
|
||||||
|
var origin = request.Headers.Origin.ToString().Trim().TrimEnd('/');
|
||||||
|
if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sameOrigin = string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
|
||||||
|
return sameOrigin || options.Value.AllowedOrigins.Any(allowed =>
|
||||||
|
string.Equals(allowed.Trim().TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasValidCsrfToken(HttpRequest request)
|
||||||
|
{
|
||||||
|
var cookie = request.Cookies[BrowserAuthOptions.CsrfCookie];
|
||||||
|
var header = request.Headers[BrowserAuthOptions.CsrfHeader].ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(cookie) || string.IsNullOrWhiteSpace(header))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var left = System.Text.Encoding.UTF8.GetBytes(cookie);
|
||||||
|
var right = System.Text.Encoding.UTF8.GetBytes(header);
|
||||||
|
return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,12 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception is BrowserOriginException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, "browser_origin_rejected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (exception is TenantContextConflictException)
|
if (exception is TenantContextConflictException)
|
||||||
{
|
{
|
||||||
await WriteProblemAsync(
|
await WriteProblemAsync(
|
||||||
@@ -380,6 +386,7 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
"tenant_access_denied" => StatusCodes.Status403Forbidden,
|
"tenant_access_denied" => StatusCodes.Status403Forbidden,
|
||||||
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
|
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
|
||||||
"auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
"auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
||||||
|
"auth_security_unavailable" => StatusCodes.Status503ServiceUnavailable,
|
||||||
"session_revoked" => StatusCodes.Status401Unauthorized,
|
"session_revoked" => StatusCodes.Status401Unauthorized,
|
||||||
_ => StatusCodes.Status401Unauthorized
|
_ => StatusCodes.Status401Unauthorized
|
||||||
};
|
};
|
||||||
@@ -494,7 +501,7 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
{
|
{
|
||||||
return code switch
|
return code switch
|
||||||
{
|
{
|
||||||
"platform_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden,
|
"platform_access_denied" or "tenant_access_denied" or "capability_not_available" => StatusCodes.Status403Forbidden,
|
||||||
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
|
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
|
||||||
_ => StatusCodes.Status400BadRequest
|
_ => StatusCodes.Status400BadRequest
|
||||||
};
|
};
|
||||||
|
|||||||
12
Tiku.Api/Options/BrowserAuthOptions.cs
Normal file
12
Tiku.Api/Options/BrowserAuthOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Tiku.Api.Options;
|
||||||
|
|
||||||
|
public sealed class BrowserAuthOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "BrowserAuth";
|
||||||
|
public const string AccessCookie = "__Host-tiku-at";
|
||||||
|
public const string RefreshCookie = "__Secure-tiku-rt";
|
||||||
|
public const string CsrfCookie = "__Host-tiku-csrf";
|
||||||
|
public const string CsrfHeader = "X-CSRF-Token";
|
||||||
|
|
||||||
|
public string[] AllowedOrigins { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.Net;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
|
|
||||||
namespace Tiku.Api.Options;
|
namespace Tiku.Api.Options;
|
||||||
@@ -41,6 +42,32 @@ public static class OptionsValidation
|
|||||||
return options.AllowedOrigins.All(IsHttpOrigin);
|
return options.AllowedOrigins.All(IsHttpOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool BeValidTenantResolutionOptions(
|
||||||
|
TenantResolutionOptions options,
|
||||||
|
IConfiguration configuration,
|
||||||
|
bool isProduction)
|
||||||
|
{
|
||||||
|
if (!isProduction)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformHosts = options.PlatformHosts
|
||||||
|
.Where(host => !string.IsNullOrWhiteSpace(host))
|
||||||
|
.Select(host => host.Trim())
|
||||||
|
.ToArray();
|
||||||
|
var hasFormalHost = platformHosts.Any(host =>
|
||||||
|
!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(host, "::1", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var allowedHosts = configuration["AllowedHosts"];
|
||||||
|
return hasFormalHost &&
|
||||||
|
options.TrustedProxyAddresses.Any(address => IPAddress.TryParse(address, out _)) &&
|
||||||
|
!string.IsNullOrWhiteSpace(allowedHosts) &&
|
||||||
|
!allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Contains("*", StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsHttpOrigin(string origin)
|
private static bool IsHttpOrigin(string origin)
|
||||||
{
|
{
|
||||||
return Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
|
return Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
|
|
||||||
namespace Tiku.Api.Security;
|
namespace Tiku.Api.Security;
|
||||||
@@ -73,7 +74,10 @@ internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext ac
|
|||||||
Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null;
|
Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
|
internal sealed class TenantPermissionAuthorizationHandler(
|
||||||
|
ICurrentAccessContext accessContext,
|
||||||
|
ICapabilityAccessEvaluator capabilityAccessEvaluator,
|
||||||
|
IHttpContextAccessor httpContextAccessor) :
|
||||||
AuthorizationHandler<TenantPermissionRequirement>
|
AuthorizationHandler<TenantPermissionRequirement>
|
||||||
{
|
{
|
||||||
protected override async Task HandleRequirementAsync(
|
protected override async Task HandleRequirementAsync(
|
||||||
@@ -86,11 +90,27 @@ internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
var access = await accessContext.GetAsync();
|
var access = await accessContext.GetAsync();
|
||||||
if (access.HasTenantPermission(requirement.PermissionCode))
|
var moduleCode = ResolveModuleCode(requirement.PermissionCode);
|
||||||
|
var operation = IsSafeMethod(httpContextAccessor.HttpContext?.Request.Method)
|
||||||
|
? CapabilityOperation.Read
|
||||||
|
: CapabilityOperation.Write;
|
||||||
|
if (access.TenantId is { } tenantId &&
|
||||||
|
access.HasTenantPermission(requirement.PermissionCode) &&
|
||||||
|
await capabilityAccessEvaluator.IsAllowedAsync(tenantId, moduleCode, operation))
|
||||||
{
|
{
|
||||||
context.Succeed(requirement);
|
context.Succeed(requirement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveModuleCode(string permissionCode)
|
||||||
|
{
|
||||||
|
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSafeMethod(string? method) =>
|
||||||
|
method is not null &&
|
||||||
|
(HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
|
internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
|
||||||
@@ -176,6 +196,8 @@ public static class AccessAuthorizationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
|
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
services.TryAddScoped<ICapabilityAccessEvaluator, CompatibilityCapabilityAccessEvaluator>();
|
||||||
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
|
||||||
@@ -252,3 +274,18 @@ public static class AccessAuthorizationServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class CompatibilityCapabilityAccessEvaluator : ICapabilityAccessEvaluator
|
||||||
|
{
|
||||||
|
public Task<bool> IsAllowedAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string moduleCode,
|
||||||
|
CapabilityOperation operation,
|
||||||
|
CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CapabilityOperation operation = CapabilityOperation.Read,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|||||||
79
Tiku.Api/Security/EndpointAuthorizationMetadata.cs
Normal file
79
Tiku.Api/Security/EndpointAuthorizationMetadata.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Routing;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Security;
|
||||||
|
|
||||||
|
public sealed record EndpointAuthorizationMetadata(
|
||||||
|
string Realm,
|
||||||
|
string? Module,
|
||||||
|
string? Permission,
|
||||||
|
CapabilityOperation Operation,
|
||||||
|
bool RequiresAllDataScope,
|
||||||
|
string AuditAction);
|
||||||
|
|
||||||
|
internal sealed class EndpointAuthorizationMetadataConvention : IApplicationModelConvention
|
||||||
|
{
|
||||||
|
public void Apply(ApplicationModel application)
|
||||||
|
{
|
||||||
|
foreach (var controller in application.Controllers)
|
||||||
|
{
|
||||||
|
foreach (var action in controller.Actions)
|
||||||
|
{
|
||||||
|
var anonymous = controller.Attributes.OfType<AllowAnonymousAttribute>().Any() ||
|
||||||
|
action.Attributes.OfType<AllowAnonymousAttribute>().Any();
|
||||||
|
if (anonymous)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var policies = controller.Attributes.OfType<AuthorizeAttribute>()
|
||||||
|
.Concat(action.Attributes.OfType<AuthorizeAttribute>())
|
||||||
|
.Select(attribute => attribute.Policy)
|
||||||
|
.Where(policy => !string.IsNullOrWhiteSpace(policy))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToArray();
|
||||||
|
var permission = policies.FirstOrDefault(policy =>
|
||||||
|
BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy));
|
||||||
|
var realm = permission is not null && BackendPermissions.Platform.Contains(permission) ||
|
||||||
|
policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal))
|
||||||
|
? "platform"
|
||||||
|
: permission is not null && BackendPermissions.Tenant.Contains(permission) ||
|
||||||
|
policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal))
|
||||||
|
? "tenant"
|
||||||
|
: "authenticated";
|
||||||
|
var module = permission is null ? null : ResolveModule(permission);
|
||||||
|
var httpMethods = action.Attributes.OfType<HttpMethodAttribute>()
|
||||||
|
.SelectMany(attribute => attribute.HttpMethods)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var operation = httpMethods.All(IsSafeMethod)
|
||||||
|
? CapabilityOperation.Read
|
||||||
|
: CapabilityOperation.Write;
|
||||||
|
var route = $"{controller.ControllerName}.{action.ActionName}";
|
||||||
|
var metadata = new EndpointAuthorizationMetadata(
|
||||||
|
realm,
|
||||||
|
module,
|
||||||
|
permission,
|
||||||
|
operation,
|
||||||
|
policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) ||
|
||||||
|
policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal),
|
||||||
|
$"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}");
|
||||||
|
foreach (var selector in action.Selectors)
|
||||||
|
{
|
||||||
|
selector.EndpointMetadata.Add(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveModule(string permission)
|
||||||
|
{
|
||||||
|
var parts = permission.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permission.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSafeMethod(string method) =>
|
||||||
|
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
|
||||||
|
}
|
||||||
@@ -65,6 +65,18 @@
|
|||||||
"DeviceRequestsPerHour": 10
|
"DeviceRequestsPerHour": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Redis": {
|
||||||
|
"KeyPrefix": "tiku"
|
||||||
|
},
|
||||||
|
"RabbitMq": {
|
||||||
|
"Host": "",
|
||||||
|
"VirtualHost": "/",
|
||||||
|
"Username": "",
|
||||||
|
"Password": ""
|
||||||
|
},
|
||||||
|
"BrowserAuth": {
|
||||||
|
"AllowedOrigins": []
|
||||||
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
"DefaultProvider": "aliyun_oss",
|
"DefaultProvider": "aliyun_oss",
|
||||||
"DefaultBucket": "tenant-assets",
|
"DefaultBucket": "tenant-assets",
|
||||||
|
|||||||
@@ -20,5 +20,8 @@ public sealed class SmsRateLimitedException()
|
|||||||
public sealed class AuthProviderNotConfiguredException(string provider)
|
public sealed class AuthProviderNotConfiguredException(string provider)
|
||||||
: AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured.");
|
: AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured.");
|
||||||
|
|
||||||
|
public sealed class AuthSecurityUnavailableException()
|
||||||
|
: AuthException("auth_security_unavailable", "Authentication security services are unavailable.");
|
||||||
|
|
||||||
public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge")
|
public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge")
|
||||||
: AuthException(code, "The authentication challenge is invalid, consumed, or expired.");
|
: AuthException(code, "The authentication challenge is invalid, consumed, or expired.");
|
||||||
|
|||||||
21
Tiku.Application/Security/ICapabilityAccessEvaluator.cs
Normal file
21
Tiku.Application/Security/ICapabilityAccessEvaluator.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Tiku.Application.Security;
|
||||||
|
|
||||||
|
public enum CapabilityOperation
|
||||||
|
{
|
||||||
|
Read,
|
||||||
|
Write
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICapabilityAccessEvaluator
|
||||||
|
{
|
||||||
|
Task<bool> IsAllowedAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string moduleCode,
|
||||||
|
CapabilityOperation operation,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CapabilityOperation operation = CapabilityOperation.Read,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
23
Tiku.Application/Security/IRedisSecurityStore.cs
Normal file
23
Tiku.Application/Security/IRedisSecurityStore.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Tiku.Application.Security;
|
||||||
|
|
||||||
|
public sealed record DistributedRateLimitBucket(string Key, int PermitLimit, TimeSpan Window);
|
||||||
|
|
||||||
|
public sealed record DistributedRateLimitResult(bool Allowed, TimeSpan? RetryAfter = null);
|
||||||
|
|
||||||
|
public interface IRedisSecurityStore
|
||||||
|
{
|
||||||
|
bool IsConfigured { get; }
|
||||||
|
|
||||||
|
Task<DistributedRateLimitResult> ConsumeAsync(
|
||||||
|
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> PingAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task SetInvalidationVersionAsync(
|
||||||
|
string realm,
|
||||||
|
Guid? tenantId,
|
||||||
|
Guid? userId,
|
||||||
|
long version,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -1,16 +1,31 @@
|
|||||||
namespace Tiku.Application.Security;
|
namespace Tiku.Application.Security;
|
||||||
|
|
||||||
|
public enum SystemScopeCallerType
|
||||||
|
{
|
||||||
|
Platform,
|
||||||
|
Worker,
|
||||||
|
Migrator,
|
||||||
|
PublicQuestionBank,
|
||||||
|
Test
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SystemScopeRequest(
|
||||||
|
Guid? TargetTenantId,
|
||||||
|
SystemScopeCallerType CallerType,
|
||||||
|
string Caller,
|
||||||
|
string Reason,
|
||||||
|
string CorrelationId);
|
||||||
|
|
||||||
public interface ITenantExecutionScope
|
public interface ITenantExecutionScope
|
||||||
{
|
{
|
||||||
Task ExecuteAsync(
|
Task ExecuteAsync(
|
||||||
Guid? targetTenantId,
|
SystemScopeRequest request,
|
||||||
string reason,
|
|
||||||
Func<IServiceProvider, CancellationToken, Task> operation,
|
Func<IServiceProvider, CancellationToken, Task> operation,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<TResult> ExecuteAsync<TResult>(
|
Task<TResult> ExecuteAsync<TResult>(
|
||||||
Guid? targetTenantId,
|
SystemScopeRequest request,
|
||||||
string reason,
|
|
||||||
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
Tiku.Contracts/SecurityEvents.cs
Normal file
36
Tiku.Contracts/SecurityEvents.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
namespace Tiku.Contracts;
|
||||||
|
|
||||||
|
public sealed record AuthorizationStateChangedV1(
|
||||||
|
Guid EventId,
|
||||||
|
Guid? TenantId,
|
||||||
|
Guid? UserId,
|
||||||
|
string ChangeKind,
|
||||||
|
long Version,
|
||||||
|
DateTimeOffset OccurredAt,
|
||||||
|
string CorrelationId);
|
||||||
|
|
||||||
|
public sealed record TenantCapabilityChangedV1(
|
||||||
|
Guid EventId,
|
||||||
|
Guid TenantId,
|
||||||
|
string ModuleCode,
|
||||||
|
string ChangeKind,
|
||||||
|
long Version,
|
||||||
|
DateTimeOffset OccurredAt,
|
||||||
|
string CorrelationId);
|
||||||
|
|
||||||
|
public sealed record MembershipLifecycleChangedV1(
|
||||||
|
Guid EventId,
|
||||||
|
Guid TenantId,
|
||||||
|
Guid UserId,
|
||||||
|
string PreviousStatus,
|
||||||
|
string CurrentStatus,
|
||||||
|
DateTimeOffset OccurredAt,
|
||||||
|
string CorrelationId);
|
||||||
|
|
||||||
|
public sealed record BackgroundJobRequestedV1(
|
||||||
|
Guid EventId,
|
||||||
|
Guid JobId,
|
||||||
|
Guid TenantId,
|
||||||
|
string JobType,
|
||||||
|
DateTimeOffset OccurredAt,
|
||||||
|
string CorrelationId);
|
||||||
7
Tiku.Contracts/Tiku.Contracts.csproj
Normal file
7
Tiku.Contracts/Tiku.Contracts.csproj
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -389,7 +389,7 @@ public enum EntitlementScopeType { Tenant, Region, Module, Subject, QuestionBank
|
|||||||
public enum EntitlementStatus { Active, Revoked, Expired }
|
public enum EntitlementStatus { Active, Revoked, Expired }
|
||||||
public enum DiscountType { Percent, Fixed }
|
public enum DiscountType { Percent, Fixed }
|
||||||
public enum CouponRedemptionStatus { Claimed, Used, Expired, Cancelled }
|
public enum CouponRedemptionStatus { Claimed, Used, Expired, Cancelled }
|
||||||
public enum TenantSubscriptionStatus { Trial, Active, PastDue, Cancelled }
|
public enum TenantSubscriptionStatus { Trial, Active, PastDue, Cancelled, Expired }
|
||||||
public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled }
|
public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled }
|
||||||
public enum RefundEntitlementAction { None, RevokeOnSuccess }
|
public enum RefundEntitlementAction { None, RevokeOnSuccess }
|
||||||
public enum ReconciliationBillType { Payment, Refund, Combined }
|
public enum ReconciliationBillType { Payment, Refund, Combined }
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ public sealed class PlatformSaasPlan : AuditableEntity
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ProductModule : AuditableEntity
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public ProductModuleStatus Status { get; set; } = ProductModuleStatus.Active;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanModuleEntitlement : Entity
|
||||||
|
{
|
||||||
|
public string PlanCode { get; set; } = string.Empty;
|
||||||
|
public string ModuleCode { get; set; } = string.Empty;
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TenantModuleOverride : AuditableTenantEntity
|
||||||
|
{
|
||||||
|
public string ModuleCode { get; set; } = string.Empty;
|
||||||
|
public TenantModuleOverrideMode Mode { get; set; } = TenantModuleOverrideMode.Disabled;
|
||||||
|
public DateTimeOffset? ExpiresAt { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned
|
public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned
|
||||||
{
|
{
|
||||||
public Guid TenantId { get; set; }
|
public Guid TenantId { get; set; }
|
||||||
@@ -177,6 +201,8 @@ public sealed class PlatformDunningNotificationEvent : AuditableTenantEntity
|
|||||||
|
|
||||||
public enum PlatformBillingCycle { Monthly, Quarterly, Yearly, OneTime }
|
public enum PlatformBillingCycle { Monthly, Quarterly, Yearly, OneTime }
|
||||||
public enum PlatformSaasPlanStatus { Active, Archived }
|
public enum PlatformSaasPlanStatus { Active, Archived }
|
||||||
|
public enum ProductModuleStatus { Active, Archived }
|
||||||
|
public enum TenantModuleOverrideMode { Enabled, Disabled }
|
||||||
public enum TenantInvoiceTitleType { None, NormalVat, SpecialVat }
|
public enum TenantInvoiceTitleType { None, NormalVat, SpecialVat }
|
||||||
public enum TenantInvoiceType { Subscription, ServiceFee, UsageOverage, ManualAdjustment }
|
public enum TenantInvoiceType { Subscription, ServiceFee, UsageOverage, ManualAdjustment }
|
||||||
public enum TenantInvoiceStatus { Draft, Issued, Paid, Void, Overdue }
|
public enum TenantInvoiceStatus { Draft, Issued, Paid, Void, Overdue }
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ public sealed class TenantSettings : IHasTimestamps, ITenantOwned
|
|||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class TenantAuthPolicy : IHasTimestamps, ITenantOwned
|
||||||
|
{
|
||||||
|
public Guid TenantId { get; set; }
|
||||||
|
public bool AllowExternalStudentSelfRegistration { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TenantFrontendConfig : AuditableTenantEntity
|
public sealed class TenantFrontendConfig : AuditableTenantEntity
|
||||||
{
|
{
|
||||||
public int SchemaVersion { get; set; } = 1;
|
public int SchemaVersion { get; set; } = 1;
|
||||||
|
|||||||
@@ -404,6 +404,10 @@ public sealed class AuthService(
|
|||||||
request.TenantId.Value,
|
request.TenantId.Value,
|
||||||
user.Id,
|
user.Id,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
// Persist the external identity and membership together only after the
|
||||||
|
// tenant policy and existing membership state have accepted the login.
|
||||||
|
// A denied first login must not leave a user or provider identity behind.
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return await CompleteSuccessfulLoginAsync(
|
return await CompleteSuccessfulLoginAsync(
|
||||||
request.Realm,
|
request.Realm,
|
||||||
@@ -502,7 +506,6 @@ public sealed class AuthService(
|
|||||||
existingIdentity.UserId = user.Id;
|
existingIdentity.UserId = user.Id;
|
||||||
existingIdentity.OpenId = wechatIdentity.OpenId;
|
existingIdentity.OpenId = wechatIdentity.OpenId;
|
||||||
existingIdentity.UnionId = wechatIdentity.UnionId;
|
existingIdentity.UnionId = wechatIdentity.UnionId;
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -552,22 +555,26 @@ public sealed class AuthService(
|
|||||||
membership.UserId == userId &&
|
membership.UserId == userId &&
|
||||||
membership.Role == TenantRole.Student,
|
membership.Role == TenantRole.Student,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (studentMembership is null)
|
if (studentMembership is not null)
|
||||||
{
|
{
|
||||||
dbContext.TenantMemberships.Add(new TenantMembership
|
// Invited and Disabled memberships require an explicit administrator action.
|
||||||
{
|
throw new TenantAccessDeniedException();
|
||||||
TenantId = tenantId,
|
|
||||||
UserId = userId,
|
|
||||||
Role = TenantRole.Student,
|
|
||||||
Status = MembershipStatus.Active
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
studentMembership.Status = MembershipStatus.Active;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
var policy = await dbContext.TenantAuthPolicies.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||||
|
if (policy is not null && !policy.AllowExternalStudentSelfRegistration)
|
||||||
|
{
|
||||||
|
throw new TenantAccessDeniedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.TenantMemberships.Add(new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = userId,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -7,14 +8,25 @@ using Tiku.Application.Auth;
|
|||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Auth;
|
namespace Tiku.Infrastructure.Auth;
|
||||||
|
|
||||||
public sealed class SmsVerificationService(
|
public sealed class SmsVerificationService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
ISmsProvider smsProvider,
|
ISmsProvider smsProvider,
|
||||||
|
IRedisSecurityStore redisSecurityStore,
|
||||||
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
||||||
{
|
{
|
||||||
|
public SmsVerificationService(
|
||||||
|
TikuDbContext dbContext,
|
||||||
|
ISmsProvider smsProvider,
|
||||||
|
IOptions<SmsSecurityOptions> securityOptions)
|
||||||
|
: this(dbContext, smsProvider, new NullRedisSecurityStore(), securityOptions)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
||||||
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||||
private readonly SmsSecurityOptions options = securityOptions.Value;
|
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||||
@@ -141,6 +153,7 @@ public sealed class SmsVerificationService(
|
|||||||
|
|
||||||
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
await ConsumeVerificationLimitAsync(tenantId, normalizedPhone, purpose, cancellationToken);
|
||||||
var codeHash = SmsCodeHashing.Hash(
|
var codeHash = SmsCodeHashing.Hash(
|
||||||
tenantId,
|
tenantId,
|
||||||
normalizedPhone,
|
normalizedPhone,
|
||||||
@@ -184,6 +197,39 @@ public sealed class SmsVerificationService(
|
|||||||
throw new InvalidCredentialsException("invalid_sms_code");
|
throw new InvalidCredentialsException("invalid_sms_code");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ConsumeVerificationLimitAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string phone,
|
||||||
|
SmsPurpose purpose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!redisSecurityStore.IsConfigured)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone)))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await redisSecurityStore.ConsumeAsync(
|
||||||
|
[
|
||||||
|
new DistributedRateLimitBucket(
|
||||||
|
$"sms-verify:{tenantId:N}:{purpose.ToString().ToLowerInvariant()}:{phoneHash}",
|
||||||
|
options.MaxVerificationAttempts,
|
||||||
|
CodeLifetime)
|
||||||
|
], cancellationToken);
|
||||||
|
if (!result.Allowed)
|
||||||
|
{
|
||||||
|
throw new SmsRateLimitedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (RedisSecurityUnavailableException)
|
||||||
|
{
|
||||||
|
throw new AuthSecurityUnavailableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ConsumeRateLimitsAsync(
|
private async Task ConsumeRateLimitsAsync(
|
||||||
SendSmsCodeRequest request,
|
SendSmsCodeRequest request,
|
||||||
string phone,
|
string phone,
|
||||||
@@ -193,6 +239,27 @@ public sealed class SmsVerificationService(
|
|||||||
var limits = BuildRateLimits(request, phone);
|
var limits = BuildRateLimits(request, phone);
|
||||||
var bucketStart = TruncateToHour(now);
|
var bucketStart = TruncateToHour(now);
|
||||||
|
|
||||||
|
if (redisSecurityStore.IsConfigured)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var distributed = await redisSecurityStore.ConsumeAsync(
|
||||||
|
limits.Select(limit => new DistributedRateLimitBucket(
|
||||||
|
$"sms-send:{request.TenantId:N}:{ToSnakeCase(limit.Dimension)}:{limit.ScopeHash}",
|
||||||
|
limit.Maximum,
|
||||||
|
TimeSpan.FromHours(1))).ToArray(),
|
||||||
|
cancellationToken);
|
||||||
|
if (!distributed.Allowed)
|
||||||
|
{
|
||||||
|
throw new SmsRateLimitedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (RedisSecurityUnavailableException)
|
||||||
|
{
|
||||||
|
throw new AuthSecurityUnavailableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!dbContext.Database.IsRelational())
|
if (!dbContext.Database.IsRelational())
|
||||||
{
|
{
|
||||||
await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
|
await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace Tiku.Infrastructure.Backoffice;
|
|||||||
|
|
||||||
internal sealed class BackofficeService(
|
internal sealed class BackofficeService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IOperationAuditService auditService) : IBackofficeService
|
IOperationAuditService auditService,
|
||||||
|
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackofficeService
|
||||||
{
|
{
|
||||||
private static readonly BuiltinPermission[] BuiltinPermissions =
|
private static readonly BuiltinPermission[] BuiltinPermissions =
|
||||||
[
|
[
|
||||||
@@ -63,7 +64,11 @@ internal sealed class BackofficeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await EnsureCatalogAsync(cancellationToken);
|
await EnsureCatalogAsync(cancellationToken);
|
||||||
var permissionCodes = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray();
|
var permissionCodes = await FilterTenantPermissionCodesAsync(
|
||||||
|
access.TenantId.Value,
|
||||||
|
access.TenantPermissions,
|
||||||
|
CapabilityOperation.Read,
|
||||||
|
cancellationToken);
|
||||||
var menus = await LoadEffectiveMenusAsync(
|
var menus = await LoadEffectiveMenusAsync(
|
||||||
BackendPermissionArea.Tenant,
|
BackendPermissionArea.Tenant,
|
||||||
permissionCodes,
|
permissionCodes,
|
||||||
@@ -100,10 +105,18 @@ internal sealed class BackofficeService(
|
|||||||
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
|
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
|
||||||
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||||
.ToArrayAsync(cancellationToken);
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var enabledPermissionCodes = await FilterTenantPermissionCodesAsync(
|
||||||
|
tenantId,
|
||||||
|
permissions.Select(item => item.Code),
|
||||||
|
CapabilityOperation.Read,
|
||||||
|
cancellationToken);
|
||||||
|
permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)).ToArray();
|
||||||
var menus = await dbContext.BackendMenus.AsNoTracking()
|
var menus = await dbContext.BackendMenus.AsNoTracking()
|
||||||
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
|
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
|
||||||
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
|
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||||
.ToArrayAsync(cancellationToken);
|
.ToArrayAsync(cancellationToken);
|
||||||
|
menus = menus.Where(item => item.PermissionCode is null ||
|
||||||
|
enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)).ToArray();
|
||||||
return new BackofficeBootstrap(
|
return new BackofficeBootstrap(
|
||||||
permissions.Select(ToPermissionItem).ToArray(),
|
permissions.Select(ToPermissionItem).ToArray(),
|
||||||
menus.Select(ToMenuItem).ToArray(),
|
menus.Select(ToMenuItem).ToArray(),
|
||||||
@@ -350,6 +363,19 @@ internal sealed class BackofficeService(
|
|||||||
var normalizedPermissions = NormalizeCodes(permissionCodes);
|
var normalizedPermissions = NormalizeCodes(permissionCodes);
|
||||||
var normalizedMenus = NormalizeCodes(menuCodes);
|
var normalizedMenus = NormalizeCodes(menuCodes);
|
||||||
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
|
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
|
||||||
|
foreach (var permissionCode in normalizedPermissions)
|
||||||
|
{
|
||||||
|
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||||
|
tenantId,
|
||||||
|
ResolveModuleCode(permissionCode),
|
||||||
|
CapabilityOperation.Write,
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
throw new BackofficeException(
|
||||||
|
"One or more permissions belong to a module unavailable to this tenant.",
|
||||||
|
"capability_not_available");
|
||||||
|
}
|
||||||
|
}
|
||||||
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
|
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
|
||||||
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||||
await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||||
@@ -401,6 +427,33 @@ internal sealed class BackofficeService(
|
|||||||
return menus.Select(ToMenuItem).ToArray();
|
return menus.Select(ToMenuItem).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string[]> FilterTenantPermissionCodesAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
IEnumerable<string> permissionCodes,
|
||||||
|
CapabilityOperation operation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var enabled = new List<string>();
|
||||||
|
foreach (var permissionCode in permissionCodes.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (await capabilityAccessEvaluator.IsAllowedAsync(
|
||||||
|
tenantId,
|
||||||
|
ResolveModuleCode(permissionCode),
|
||||||
|
operation,
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
enabled.Add(permissionCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveModuleCode(string permissionCode)
|
||||||
|
{
|
||||||
|
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var count = await dbContext.BackendMenus.CountAsync(
|
var count = await dbContext.BackendMenus.CountAsync(
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ public sealed class TaxonomyService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await tenantExecutionScope.ExecuteAsync(
|
return await tenantExecutionScope.ExecuteAsync(
|
||||||
tenantId,
|
new SystemScopeRequest(
|
||||||
"List platform taxonomy with tenant extensions",
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||||
|
"List platform taxonomy with tenant extensions", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
@@ -85,8 +86,9 @@ public sealed class TaxonomyService(
|
|||||||
_ => throw new InvalidOperationException("A parent source is required when parentId is provided.")
|
_ => throw new InvalidOperationException("A parent source is required when parentId is provided.")
|
||||||
};
|
};
|
||||||
parent = await tenantExecutionScope.ExecuteAsync(
|
parent = await tenantExecutionScope.ExecuteAsync(
|
||||||
tenantId,
|
new SystemScopeRequest(
|
||||||
"Validate taxonomy extension parent ownership",
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||||
|
"Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
@@ -137,8 +139,9 @@ public sealed class TaxonomyService(
|
|||||||
{
|
{
|
||||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||||
return await tenantExecutionScope.ExecuteAsync(
|
return await tenantExecutionScope.ExecuteAsync(
|
||||||
tenantId,
|
new SystemScopeRequest(
|
||||||
"Resolve platform taxonomy owner",
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||||
|
"Resolve platform taxonomy owner", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) => await provider.GetRequiredService<TikuDbContext>()
|
async (provider, token) => await provider.GetRequiredService<TikuDbContext>()
|
||||||
.Tenants.AsNoTracking()
|
.Tenants.AsNoTracking()
|
||||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ public sealed class DirectContentService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
||||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||||
|
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
var question = new Question
|
var question = new Question
|
||||||
{
|
{
|
||||||
@@ -57,7 +59,10 @@ public sealed class DirectContentService(
|
|||||||
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
if (transaction is not null)
|
||||||
|
{
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ using Tiku.Infrastructure.StudyContent;
|
|||||||
using Tiku.Infrastructure.TenantAdmin;
|
using Tiku.Infrastructure.TenantAdmin;
|
||||||
using Tiku.Infrastructure.Tenancy;
|
using Tiku.Infrastructure.Tenancy;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using MassTransit;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure;
|
namespace Tiku.Infrastructure;
|
||||||
|
|
||||||
@@ -83,6 +86,8 @@ public static class DependencyInjection
|
|||||||
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
|
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
|
||||||
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
||||||
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||||
|
services.AddSingleton<IRedisSecurityStore, NullRedisSecurityStore>();
|
||||||
|
services.AddScoped<ISecurityEventPublisher, NullSecurityEventPublisher>();
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||||
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
|
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
|
||||||
@@ -121,6 +126,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||||
|
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
||||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||||
services.AddScoped<ICommerceService, CommerceService>();
|
services.AddScoped<ICommerceService, CommerceService>();
|
||||||
@@ -150,4 +156,78 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddRedisSecurity(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string connectionString,
|
||||||
|
string environmentName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||||
|
var options = ConfigurationOptions.Parse(connectionString);
|
||||||
|
options.AbortOnConnectFail = false;
|
||||||
|
options.ClientName = $"tiku-{environmentName.ToLowerInvariant()}";
|
||||||
|
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(options));
|
||||||
|
services.AddSingleton(provider => new RedisSecurityStore(
|
||||||
|
provider.GetRequiredService<IConnectionMultiplexer>(),
|
||||||
|
environmentName,
|
||||||
|
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<RedisSecurityStore>>()));
|
||||||
|
services.AddSingleton<IRedisSecurityStore>(provider => provider.GetRequiredService<RedisSecurityStore>());
|
||||||
|
services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddReliableMessaging(
|
||||||
|
this IServiceCollection services,
|
||||||
|
MessagingOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
if (!options.IsConfigured)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A valid RabbitMQ host URI is required.", nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddMassTransit(registration =>
|
||||||
|
{
|
||||||
|
registration.SetKebabCaseEndpointNameFormatter();
|
||||||
|
registration.ConfigureHealthCheckOptions(health =>
|
||||||
|
{
|
||||||
|
health.Name = "rabbitmq";
|
||||||
|
health.Tags.Add("ready");
|
||||||
|
});
|
||||||
|
registration.AddEntityFrameworkOutbox<TikuDbContext>(outbox =>
|
||||||
|
{
|
||||||
|
outbox.UsePostgres();
|
||||||
|
outbox.UseBusOutbox();
|
||||||
|
outbox.QueryDelay = TimeSpan.FromSeconds(1);
|
||||||
|
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
|
||||||
|
});
|
||||||
|
if (options.ConfigureConsumers)
|
||||||
|
{
|
||||||
|
registration.AddConsumer<SecurityStateChangedConsumer>(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;
|
||||||
|
endpoint.ConcurrentMessageLimit = 1;
|
||||||
|
endpoint.UseEntityFrameworkOutbox<TikuDbContext>(context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registration.UsingRabbitMq((context, configurator) =>
|
||||||
|
{
|
||||||
|
configurator.Host(new Uri(options.Host), options.VirtualHost, host =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Username)) host.Username(options.Username);
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Password)) host.Password(options.Password);
|
||||||
|
});
|
||||||
|
configurator.ConfigureEndpoints(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
services.AddScoped<ISecurityEventPublisher, MassTransitSecurityEventPublisher>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ namespace Tiku.Infrastructure.Jobs;
|
|||||||
internal sealed class BackgroundJobService(
|
internal sealed class BackgroundJobService(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService
|
ITenantExecutionScope tenantExecutionScope,
|
||||||
|
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackgroundJobService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
@@ -24,10 +25,19 @@ internal sealed class BackgroundJobService(
|
|||||||
CreateBackgroundJobCommand command,
|
CreateBackgroundJobCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var normalizedJobType = NormalizeJobType(command.JobType);
|
||||||
|
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||||
|
command.TenantId,
|
||||||
|
ResolveCapabilityModule(normalizedJobType),
|
||||||
|
CapabilityOperation.Write,
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Tenant capability does not allow this background job.");
|
||||||
|
}
|
||||||
var job = new BackgroundJob
|
var job = new BackgroundJob
|
||||||
{
|
{
|
||||||
TenantId = command.TenantId,
|
TenantId = command.TenantId,
|
||||||
JobType = NormalizeJobType(command.JobType),
|
JobType = normalizedJobType,
|
||||||
Payload = command.Payload,
|
Payload = command.Payload,
|
||||||
RunAfter = command.RunAfter,
|
RunAfter = command.RunAfter,
|
||||||
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
||||||
@@ -55,6 +65,19 @@ internal sealed class BackgroundJobService(
|
|||||||
foreach (var job in jobs)
|
foreach (var job in jobs)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||||
|
job.TenantId,
|
||||||
|
ResolveCapabilityModule(job.JobType),
|
||||||
|
CapabilityOperation.Write,
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
job.Status = BackgroundJobStatus.Failed;
|
||||||
|
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
job.LastError = "Tenant capability was revoked before job execution.";
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
processed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
job.Status = BackgroundJobStatus.Processing;
|
job.Status = BackgroundJobStatus.Processing;
|
||||||
job.LockedBy = workerId;
|
job.LockedBy = workerId;
|
||||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||||
@@ -64,8 +87,9 @@ internal sealed class BackgroundJobService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await tenantExecutionScope.ExecuteAsync(
|
var result = await tenantExecutionScope.ExecuteAsync(
|
||||||
job.TenantId,
|
new SystemScopeRequest(
|
||||||
$"Background job {job.JobType}",
|
job.TenantId, SystemScopeCallerType.Worker, workerId,
|
||||||
|
$"Background job {job.JobType}", job.Id.ToString("N")),
|
||||||
(provider, token) => ProcessCoreAsync(provider, job, token),
|
(provider, token) => ProcessCoreAsync(provider, job, token),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
job.Status = BackgroundJobStatus.Succeeded;
|
job.Status = BackgroundJobStatus.Succeeded;
|
||||||
@@ -328,6 +352,15 @@ internal sealed class BackgroundJobService(
|
|||||||
return jobType.Trim().ToLowerInvariant();
|
return jobType.Trim().ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveCapabilityModule(string jobType) => jobType switch
|
||||||
|
{
|
||||||
|
"content_export" or "content_import" or "asset_security_scan" => "content",
|
||||||
|
"statistics_aggregation" => "dashboard",
|
||||||
|
"commerce_reconciliation" => "commerce",
|
||||||
|
"tenant_domain_recheck" => "settings",
|
||||||
|
_ => "job"
|
||||||
|
};
|
||||||
|
|
||||||
private static string NormalizeProvider(string? provider)
|
private static string NormalizeProvider(string? provider)
|
||||||
{
|
{
|
||||||
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant();
|
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
|||||||
@@ -1024,8 +1024,9 @@ public sealed class LearningActivityService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var rows = await tenantExecutionScope.ExecuteAsync(
|
var rows = await tenantExecutionScope.ExecuteAsync(
|
||||||
tenantId,
|
new SystemScopeRequest(
|
||||||
"Lock published question versions for a new practice session",
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
||||||
|
"Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
@@ -1076,8 +1077,9 @@ public sealed class LearningActivityService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return tenantExecutionScope.ExecuteAsync(
|
return tenantExecutionScope.ExecuteAsync(
|
||||||
tenantId,
|
new SystemScopeRequest(
|
||||||
"Read locked question versions for a tenant practice session",
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
||||||
|
"Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
|
|||||||
13
Tiku.Infrastructure/Messaging/MessagingOptions.cs
Normal file
13
Tiku.Infrastructure/Messaging/MessagingOptions.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
public sealed class MessagingOptions
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
public string VirtualHost { get; set; } = "/";
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public bool ConfigureConsumers { get; set; }
|
||||||
|
|
||||||
|
public bool IsConfigured => Uri.TryCreate(Host, UriKind.Absolute, out var uri) &&
|
||||||
|
uri.Scheme is "rabbitmq" or "amqp" or "amqps";
|
||||||
|
}
|
||||||
70
Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs
Normal file
70
Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using Tiku.Contracts;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
public interface ISecurityEventPublisher
|
||||||
|
{
|
||||||
|
Task AuthorizationChangedAsync(
|
||||||
|
Guid? tenantId,
|
||||||
|
Guid? userId,
|
||||||
|
string changeKind,
|
||||||
|
long version,
|
||||||
|
string correlationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task CapabilityChangedAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string moduleCode,
|
||||||
|
string changeKind,
|
||||||
|
long version,
|
||||||
|
string correlationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task MembershipChangedAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid userId,
|
||||||
|
string previousStatus,
|
||||||
|
string currentStatus,
|
||||||
|
string correlationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class NullSecurityEventPublisher : ISecurityEventPublisher
|
||||||
|
{
|
||||||
|
public Task AuthorizationChangedAsync(
|
||||||
|
Guid? tenantId, Guid? userId, string changeKind, long version,
|
||||||
|
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task CapabilityChangedAsync(
|
||||||
|
Guid tenantId, string moduleCode, string changeKind, long version,
|
||||||
|
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task MembershipChangedAsync(
|
||||||
|
Guid tenantId, Guid userId, string previousStatus, string currentStatus,
|
||||||
|
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MassTransitSecurityEventPublisher(IPublishEndpoint publishEndpoint) : ISecurityEventPublisher
|
||||||
|
{
|
||||||
|
public Task AuthorizationChangedAsync(
|
||||||
|
Guid? tenantId, Guid? userId, string changeKind, long version,
|
||||||
|
string correlationId, CancellationToken cancellationToken = default) =>
|
||||||
|
publishEndpoint.Publish(new AuthorizationStateChangedV1(
|
||||||
|
Guid.NewGuid(), tenantId, userId, changeKind, version,
|
||||||
|
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||||
|
|
||||||
|
public Task CapabilityChangedAsync(
|
||||||
|
Guid tenantId, string moduleCode, string changeKind, long version,
|
||||||
|
string correlationId, CancellationToken cancellationToken = default) =>
|
||||||
|
publishEndpoint.Publish(new TenantCapabilityChangedV1(
|
||||||
|
Guid.NewGuid(), tenantId, moduleCode, changeKind, version,
|
||||||
|
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||||
|
|
||||||
|
public Task MembershipChangedAsync(
|
||||||
|
Guid tenantId, Guid userId, string previousStatus, string currentStatus,
|
||||||
|
string correlationId, CancellationToken cancellationToken = default) =>
|
||||||
|
publishEndpoint.Publish(new MembershipLifecycleChangedV1(
|
||||||
|
Guid.NewGuid(), tenantId, userId, previousStatus, currentStatus,
|
||||||
|
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Contracts;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
internal sealed class SecurityStateChangedConsumer(IRedisSecurityStore redisSecurityStore) :
|
||||||
|
IConsumer<AuthorizationStateChangedV1>,
|
||||||
|
IConsumer<TenantCapabilityChangedV1>,
|
||||||
|
IConsumer<MembershipLifecycleChangedV1>
|
||||||
|
{
|
||||||
|
public Task Consume(ConsumeContext<AuthorizationStateChangedV1> context) =>
|
||||||
|
redisSecurityStore.SetInvalidationVersionAsync(
|
||||||
|
"authorization", context.Message.TenantId, context.Message.UserId,
|
||||||
|
context.Message.Version, context.CancellationToken);
|
||||||
|
|
||||||
|
public Task Consume(ConsumeContext<TenantCapabilityChangedV1> context) =>
|
||||||
|
redisSecurityStore.SetInvalidationVersionAsync(
|
||||||
|
$"capability-{context.Message.ModuleCode}", context.Message.TenantId, null,
|
||||||
|
context.Message.Version, context.CancellationToken);
|
||||||
|
|
||||||
|
public Task Consume(ConsumeContext<MembershipLifecycleChangedV1> context) =>
|
||||||
|
redisSecurityStore.SetInvalidationVersionAsync(
|
||||||
|
"membership", context.Message.TenantId, context.Message.UserId,
|
||||||
|
context.Message.OccurredAt.ToUnixTimeMilliseconds(), context.CancellationToken);
|
||||||
|
}
|
||||||
@@ -27,6 +27,56 @@ internal sealed class PlatformSaasPlanConfiguration : IEntityTypeConfiguration<P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class ProductModuleConfiguration : IEntityTypeConfiguration<ProductModule>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProductModule> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureEntity("product_modules");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.Code).HasMaxLength(100);
|
||||||
|
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||||
|
builder.Property(entity => entity.Description).HasMaxLength(1000);
|
||||||
|
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||||
|
builder.HasIndex(entity => entity.Code).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PlanModuleEntitlementConfiguration : IEntityTypeConfiguration<PlanModuleEntitlement>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PlanModuleEntitlement> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureEntity("plan_module_entitlements");
|
||||||
|
builder.Property(entity => entity.PlanCode).HasMaxLength(100);
|
||||||
|
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
|
||||||
|
builder.HasIndex(entity => new { entity.PlanCode, entity.ModuleCode }).IsUnique();
|
||||||
|
builder.HasOne<PlatformSaasPlan>().WithMany()
|
||||||
|
.HasPrincipalKey(entity => entity.Code)
|
||||||
|
.HasForeignKey(entity => entity.PlanCode)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne<ProductModule>().WithMany()
|
||||||
|
.HasPrincipalKey(entity => entity.Code)
|
||||||
|
.HasForeignKey(entity => entity.ModuleCode)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TenantModuleOverrideConfiguration : IEntityTypeConfiguration<TenantModuleOverride>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TenantModuleOverride> builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureTenantEntity("tenant_module_overrides");
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
|
||||||
|
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
|
||||||
|
builder.Property(entity => entity.Reason).HasMaxLength(1000);
|
||||||
|
builder.HasIndex(entity => new { entity.TenantId, entity.ModuleCode }).IsUnique();
|
||||||
|
builder.HasOne<ProductModule>().WithMany()
|
||||||
|
.HasPrincipalKey(entity => entity.Code)
|
||||||
|
.HasForeignKey(entity => entity.ModuleCode)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfiguration<TenantBillingProfile>
|
internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfiguration<TenantBillingProfile>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<TenantBillingProfile> builder)
|
public void Configure(EntityTypeBuilder<TenantBillingProfile> builder)
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration<Ten
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class TenantAuthPolicyConfiguration : IEntityTypeConfiguration<TenantAuthPolicy>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TenantAuthPolicy> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("tenant_auth_policies");
|
||||||
|
builder.HasKey(entity => entity.TenantId);
|
||||||
|
builder.ConfigureTimestamps();
|
||||||
|
builder.Property(entity => entity.AllowExternalStudentSelfRegistration).HasDefaultValue(false);
|
||||||
|
builder.HasOne<Tenant>().WithOne()
|
||||||
|
.HasForeignKey<TenantAuthPolicy>(entity => entity.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration<TenantFrontendConfig>
|
internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration<TenantFrontendConfig>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<TenantFrontendConfig> builder)
|
public void Configure(EntityTypeBuilder<TenantFrontendConfig> builder)
|
||||||
|
|||||||
18569
Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs
generated
Normal file
18569
Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDistributedSecurityFoundation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddUniqueConstraint(
|
||||||
|
name: "ak_platform_saas_plans_code",
|
||||||
|
table: "platform_saas_plans",
|
||||||
|
column: "code");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "inbox_state",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
consumer_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
|
||||||
|
received = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
receive_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
consumed = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_inbox_state", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_inbox_state_message_id_consumer_id", x => new { x.message_id, x.consumer_id });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "outbox_state",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
outbox_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
|
||||||
|
created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_outbox_state", x => x.outbox_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "product_modules",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_product_modules", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_product_modules_code", x => x.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "tenant_auth_policies",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
allow_external_student_self_registration = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_tenant_auth_policies", x => x.tenant_id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_tenant_auth_policies_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Existing tenants retain the historical external-login behavior. New tenants
|
||||||
|
// receive an explicit fail-closed policy when created by PlatformAdminService.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT INTO tenant_auth_policies
|
||||||
|
(tenant_id, allow_external_student_self_registration, created_at, updated_at)
|
||||||
|
SELECT id, TRUE, now(), now()
|
||||||
|
FROM tenants
|
||||||
|
ON CONFLICT (tenant_id) DO NOTHING;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "outbox_message",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
sequence_number = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
enqueue_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
sent_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
headers = table.Column<string>(type: "text", nullable: true),
|
||||||
|
properties = table.Column<string>(type: "text", nullable: true),
|
||||||
|
inbox_message_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
inbox_consumer_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
outbox_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
content_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
message_type = table.Column<string>(type: "text", nullable: false),
|
||||||
|
body = table.Column<string>(type: "text", nullable: false),
|
||||||
|
conversation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
correlation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
initiator_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
request_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
source_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
destination_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
response_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
fault_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_outbox_message", x => x.sequence_number);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~",
|
||||||
|
columns: x => new { x.inbox_message_id, x.inbox_consumer_id },
|
||||||
|
principalTable: "inbox_state",
|
||||||
|
principalColumns: new[] { "message_id", "consumer_id" });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_outbox_message_outbox_state_outbox_id",
|
||||||
|
column: x => x.outbox_id,
|
||||||
|
principalTable: "outbox_state",
|
||||||
|
principalColumn: "outbox_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "plan_module_entitlements",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
plan_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
enabled = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_plan_module_entitlements", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_plan_module_entitlements_platform_saas_plans_plan_code",
|
||||||
|
column: x => x.plan_code,
|
||||||
|
principalTable: "platform_saas_plans",
|
||||||
|
principalColumn: "code",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_plan_module_entitlements_product_modules_module_code",
|
||||||
|
column: x => x.module_code,
|
||||||
|
principalTable: "product_modules",
|
||||||
|
principalColumn: "code",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "tenant_module_overrides",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_tenant_module_overrides", x => x.id);
|
||||||
|
table.UniqueConstraint("ak_tenant_module_overrides_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_tenant_module_overrides_product_modules_module_code",
|
||||||
|
column: x => x.module_code,
|
||||||
|
principalTable: "product_modules",
|
||||||
|
principalColumn: "code",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_tenant_module_overrides_tenants_tenant_id",
|
||||||
|
column: x => x.tenant_id,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_inbox_state_delivered",
|
||||||
|
table: "inbox_state",
|
||||||
|
column: "delivered");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_message_enqueue_time",
|
||||||
|
table: "outbox_message",
|
||||||
|
column: "enqueue_time");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_message_expiration_time",
|
||||||
|
table: "outbox_message",
|
||||||
|
column: "expiration_time");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~",
|
||||||
|
table: "outbox_message",
|
||||||
|
columns: new[] { "inbox_message_id", "inbox_consumer_id", "sequence_number" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_message_outbox_id_sequence_number",
|
||||||
|
table: "outbox_message",
|
||||||
|
columns: new[] { "outbox_id", "sequence_number" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_state_created",
|
||||||
|
table: "outbox_state",
|
||||||
|
column: "created");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_plan_module_entitlements_module_code",
|
||||||
|
table: "plan_module_entitlements",
|
||||||
|
column: "module_code");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_plan_module_entitlements_plan_code_module_code",
|
||||||
|
table: "plan_module_entitlements",
|
||||||
|
columns: new[] { "plan_code", "module_code" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_product_modules_code",
|
||||||
|
table: "product_modules",
|
||||||
|
column: "code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_tenant_module_overrides_module_code",
|
||||||
|
table: "tenant_module_overrides",
|
||||||
|
column: "module_code");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_tenant_module_overrides_tenant_id_module_code",
|
||||||
|
table: "tenant_module_overrides",
|
||||||
|
columns: new[] { "tenant_id", "module_code" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "outbox_message");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "plan_module_entitlements");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "tenant_auth_policies");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "tenant_module_overrides");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "inbox_state");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "outbox_state");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "product_modules");
|
||||||
|
|
||||||
|
migrationBuilder.DropUniqueConstraint(
|
||||||
|
name: "ak_platform_saas_plans_code",
|
||||||
|
table: "platform_saas_plans");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,224 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Consumed")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("consumed");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConsumerId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("consumer_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Delivered")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("delivered");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpirationTime")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expiration_time");
|
||||||
|
|
||||||
|
b.Property<long?>("LastSequenceNumber")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("last_sequence_number");
|
||||||
|
|
||||||
|
b.Property<Guid>("LockId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("lock_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("message_id");
|
||||||
|
|
||||||
|
b.Property<int>("ReceiveCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("receive_count");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Received")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("received");
|
||||||
|
|
||||||
|
b.Property<byte[]>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("row_version");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_inbox_state");
|
||||||
|
|
||||||
|
b.HasAlternateKey("MessageId", "ConsumerId")
|
||||||
|
.HasName("ak_inbox_state_message_id_consumer_id");
|
||||||
|
|
||||||
|
b.HasIndex("Delivered")
|
||||||
|
.HasDatabaseName("ix_inbox_state_delivered");
|
||||||
|
|
||||||
|
b.ToTable("inbox_state", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("SequenceNumber")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("sequence_number");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("body");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("content_type");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ConversationId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("conversation_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CorrelationId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("correlation_id");
|
||||||
|
|
||||||
|
b.Property<string>("DestinationAddress")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("destination_address");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EnqueueTime")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("enqueue_time");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ExpirationTime")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expiration_time");
|
||||||
|
|
||||||
|
b.Property<string>("FaultAddress")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("fault_address");
|
||||||
|
|
||||||
|
b.Property<string>("Headers")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("headers");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InboxConsumerId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("inbox_consumer_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InboxMessageId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("inbox_message_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InitiatorId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("initiator_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("message_id");
|
||||||
|
|
||||||
|
b.Property<string>("MessageType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("message_type");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OutboxId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("outbox_id");
|
||||||
|
|
||||||
|
b.Property<string>("Properties")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("properties");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RequestId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("request_id");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseAddress")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("response_address");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SentTime")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("sent_time");
|
||||||
|
|
||||||
|
b.Property<string>("SourceAddress")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("source_address");
|
||||||
|
|
||||||
|
b.HasKey("SequenceNumber")
|
||||||
|
.HasName("pk_outbox_message");
|
||||||
|
|
||||||
|
b.HasIndex("EnqueueTime")
|
||||||
|
.HasDatabaseName("ix_outbox_message_enqueue_time");
|
||||||
|
|
||||||
|
b.HasIndex("ExpirationTime")
|
||||||
|
.HasDatabaseName("ix_outbox_message_expiration_time");
|
||||||
|
|
||||||
|
b.HasIndex("OutboxId", "SequenceNumber")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_message_outbox_id_sequence_number");
|
||||||
|
|
||||||
|
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~");
|
||||||
|
|
||||||
|
b.ToTable("outbox_message", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("OutboxId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("outbox_id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Delivered")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("delivered");
|
||||||
|
|
||||||
|
b.Property<long?>("LastSequenceNumber")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("last_sequence_number");
|
||||||
|
|
||||||
|
b.Property<Guid>("LockId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("lock_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("row_version");
|
||||||
|
|
||||||
|
b.HasKey("OutboxId")
|
||||||
|
.HasName("pk_outbox_state");
|
||||||
|
|
||||||
|
b.HasIndex("Created")
|
||||||
|
.HasDatabaseName("ix_outbox_state_created");
|
||||||
|
|
||||||
|
b.ToTable("outbox_state", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -11755,6 +11973,43 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("user_notifications", (string)null);
|
b.ToTable("user_notifications", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("enabled");
|
||||||
|
|
||||||
|
b.Property<string>("ModuleCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("module_code");
|
||||||
|
|
||||||
|
b.Property<string>("PlanCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("plan_code");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_plan_module_entitlements");
|
||||||
|
|
||||||
|
b.HasIndex("ModuleCode")
|
||||||
|
.HasDatabaseName("ix_plan_module_entitlements_module_code");
|
||||||
|
|
||||||
|
b.HasIndex("PlanCode", "ModuleCode")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_plan_module_entitlements_plan_code_module_code");
|
||||||
|
|
||||||
|
b.ToTable("plan_module_entitlements", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
|
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -12304,6 +12559,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_platform_saas_plans");
|
.HasName("pk_platform_saas_plans");
|
||||||
|
|
||||||
|
b.HasAlternateKey("Code")
|
||||||
|
.HasName("ak_platform_saas_plans_code");
|
||||||
|
|
||||||
b.HasIndex("Code")
|
b.HasIndex("Code")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("ix_platform_saas_plans_code");
|
.HasDatabaseName("ix_platform_saas_plans_code");
|
||||||
@@ -12317,6 +12575,66 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tiku.Domain.Platform.ProductModule", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("code");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_product_modules");
|
||||||
|
|
||||||
|
b.HasAlternateKey("Code")
|
||||||
|
.HasName("ak_product_modules_code");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_product_modules_code");
|
||||||
|
|
||||||
|
b.ToTable("product_modules", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tiku.Domain.Platform.TenantBillingProfile", b =>
|
modelBuilder.Entity("Tiku.Domain.Platform.TenantBillingProfile", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("TenantId")
|
b.Property<Guid>("TenantId")
|
||||||
@@ -12818,6 +13136,67 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expires_at");
|
||||||
|
|
||||||
|
b.Property<string>("Mode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("mode");
|
||||||
|
|
||||||
|
b.Property<string>("ModuleCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("module_code");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)")
|
||||||
|
.HasColumnName("reason");
|
||||||
|
|
||||||
|
b.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("tenant_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_tenant_module_overrides");
|
||||||
|
|
||||||
|
b.HasAlternateKey("TenantId", "Id")
|
||||||
|
.HasName("ak_tenant_module_overrides_tenant_id_id");
|
||||||
|
|
||||||
|
b.HasIndex("ModuleCode")
|
||||||
|
.HasDatabaseName("ix_tenant_module_overrides_module_code");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId", "ModuleCode")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_tenant_module_overrides_tenant_id_module_code");
|
||||||
|
|
||||||
|
b.ToTable("tenant_module_overrides", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b =>
|
modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -13642,6 +14021,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("tenants", (string)null);
|
b.ToTable("tenants", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("tenant_id");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowExternalStudentSelfRegistration")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("allow_external_student_self_registration");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.HasKey("TenantId")
|
||||||
|
.HasName("pk_tenant_auth_policies");
|
||||||
|
|
||||||
|
b.ToTable("tenant_auth_policies", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("TenantId")
|
b.Property<Guid>("TenantId")
|
||||||
@@ -14618,6 +15027,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("tenant_student_notes", (string)null);
|
b.ToTable("tenant_student_notes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OutboxId")
|
||||||
|
.HasConstraintName("fk_outbox_message_outbox_state_outbox_id");
|
||||||
|
|
||||||
|
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InboxMessageId", "InboxConsumerId")
|
||||||
|
.HasPrincipalKey("MessageId", "ConsumerId")
|
||||||
|
.HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||||
@@ -17550,6 +17973,25 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasConstraintName("fk_user_notifications_users_user_id");
|
.HasConstraintName("fk_user_notifications_users_user_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tiku.Domain.Platform.ProductModule", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ModuleCode")
|
||||||
|
.HasPrincipalKey("Code")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_plan_module_entitlements_product_modules_module_code");
|
||||||
|
|
||||||
|
b.HasOne("Tiku.Domain.Platform.PlatformSaasPlan", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PlanCode")
|
||||||
|
.HasPrincipalKey("Code")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_plan_module_entitlements_platform_saas_plans_plan_code");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
|
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||||
@@ -17719,6 +18161,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasConstraintName("fk_tenant_invoice_reminders_tenant_invoices_tenant_id_invoice_~");
|
.HasConstraintName("fk_tenant_invoice_reminders_tenant_invoices_tenant_id_invoice_~");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tiku.Domain.Platform.ProductModule", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ModuleCode")
|
||||||
|
.HasPrincipalKey("Code")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_tenant_module_overrides_product_modules_module_code");
|
||||||
|
|
||||||
|
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_tenant_module_overrides_tenants_tenant_id");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b =>
|
modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||||
@@ -17895,6 +18355,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
|||||||
.HasConstraintName("fk_tenants_users_owner_user_id");
|
.HasConstraintName("fk_tenants_users_owner_user_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("Tiku.Domain.Tenancy.TenantAuthPolicy", "TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_tenant_auth_policies_tenants_tenant_id");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
|
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ using Tiku.Domain.Operations;
|
|||||||
using Tiku.Domain.Platform;
|
using Tiku.Domain.Platform;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
using MassTransit;
|
||||||
|
using MassTransit.EntityFrameworkCoreIntegration;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Persistence;
|
namespace Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ public sealed class TikuDbContext(
|
|||||||
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
||||||
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
|
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
|
||||||
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
|
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
|
||||||
|
public DbSet<TenantAuthPolicy> TenantAuthPolicies => Set<TenantAuthPolicy>();
|
||||||
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
|
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
|
||||||
public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
|
public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
|
||||||
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
|
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
|
||||||
@@ -178,6 +181,9 @@ public sealed class TikuDbContext(
|
|||||||
public DbSet<TenantThemeTemplate> TenantThemeTemplates => Set<TenantThemeTemplate>();
|
public DbSet<TenantThemeTemplate> TenantThemeTemplates => Set<TenantThemeTemplate>();
|
||||||
public DbSet<TenantThemeConfig> TenantThemeConfigs => Set<TenantThemeConfig>();
|
public DbSet<TenantThemeConfig> TenantThemeConfigs => Set<TenantThemeConfig>();
|
||||||
public DbSet<PlatformSaasPlan> PlatformSaasPlans => Set<PlatformSaasPlan>();
|
public DbSet<PlatformSaasPlan> PlatformSaasPlans => Set<PlatformSaasPlan>();
|
||||||
|
public DbSet<ProductModule> ProductModules => Set<ProductModule>();
|
||||||
|
public DbSet<PlanModuleEntitlement> PlanModuleEntitlements => Set<PlanModuleEntitlement>();
|
||||||
|
public DbSet<TenantModuleOverride> TenantModuleOverrides => Set<TenantModuleOverride>();
|
||||||
public DbSet<TenantBillingProfile> TenantBillingProfiles => Set<TenantBillingProfile>();
|
public DbSet<TenantBillingProfile> TenantBillingProfiles => Set<TenantBillingProfile>();
|
||||||
public DbSet<TenantInvoice> TenantInvoices => Set<TenantInvoice>();
|
public DbSet<TenantInvoice> TenantInvoices => Set<TenantInvoice>();
|
||||||
public DbSet<TenantInvoiceItem> TenantInvoiceItems => Set<TenantInvoiceItem>();
|
public DbSet<TenantInvoiceItem> TenantInvoiceItems => Set<TenantInvoiceItem>();
|
||||||
@@ -201,6 +207,12 @@ public sealed class TikuDbContext(
|
|||||||
modelBuilder.HasPostgresExtension("ltree");
|
modelBuilder.HasPostgresExtension("ltree");
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||||
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
|
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
|
||||||
|
modelBuilder.AddInboxStateEntity();
|
||||||
|
modelBuilder.AddOutboxMessageEntity();
|
||||||
|
modelBuilder.AddOutboxStateEntity();
|
||||||
|
modelBuilder.Entity<InboxState>().ToTable("inbox_state");
|
||||||
|
modelBuilder.Entity<OutboxMessage>().ToTable("outbox_message");
|
||||||
|
modelBuilder.Entity<OutboxState>().ToTable("outbox_state");
|
||||||
ApplyTenantQueryFilters(modelBuilder);
|
ApplyTenantQueryFilters(modelBuilder);
|
||||||
ValidateTenantModel(modelBuilder);
|
ValidateTenantModel(modelBuilder);
|
||||||
modelBuilder.UseSnakeCaseIdentifiers();
|
modelBuilder.UseSnakeCaseIdentifiers();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using Tiku.Domain.Platform;
|
|||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||||
|
|
||||||
@@ -152,6 +153,11 @@ internal sealed class PlatformAdminService(
|
|||||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||||
};
|
};
|
||||||
dbContext.Tenants.Add(tenant);
|
dbContext.Tenants.Add(tenant);
|
||||||
|
dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy
|
||||||
|
{
|
||||||
|
TenantId = tenant.Id,
|
||||||
|
AllowExternalStudentSelfRegistration = false
|
||||||
|
});
|
||||||
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
|
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return ToTenantItem(tenant, 0, null);
|
return ToTenantItem(tenant, 0, null);
|
||||||
@@ -164,7 +170,7 @@ internal sealed class PlatformAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||||
return await ExecuteSystemAsync("platform tenant status update", async dbContext =>
|
return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) =>
|
||||||
{
|
{
|
||||||
var tenant = await dbContext.Tenants
|
var tenant = await dbContext.Tenants
|
||||||
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||||
@@ -181,6 +187,13 @@ internal sealed class PlatformAdminService(
|
|||||||
ToBillingStatus = tenant.BillingStatus,
|
ToBillingStatus = tenant.BillingStatus,
|
||||||
command.Reason
|
command.Reason
|
||||||
});
|
});
|
||||||
|
await provider.GetRequiredService<ISecurityEventPublisher>().AuthorizationChangedAsync(
|
||||||
|
tenant.Id,
|
||||||
|
null,
|
||||||
|
"tenant_status_changed",
|
||||||
|
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
$"tenant-status-{tenant.Id:N}",
|
||||||
|
cancellationToken);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||||
var expiresAt = await dbContext.TenantSubscriptions
|
var expiresAt = await dbContext.TenantSubscriptions
|
||||||
@@ -255,7 +268,7 @@ internal sealed class PlatformAdminService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||||
return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext =>
|
return await ExecuteSystemAsync("platform tenant subscription upsert", async (provider, dbContext) =>
|
||||||
{
|
{
|
||||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||||
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
|
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
|
||||||
@@ -285,6 +298,27 @@ internal sealed class PlatformAdminService(
|
|||||||
subscription.Status,
|
subscription.Status,
|
||||||
subscription.ExpiresAt
|
subscription.ExpiresAt
|
||||||
});
|
});
|
||||||
|
var moduleCodes = await dbContext.PlanModuleEntitlements.AsNoTracking()
|
||||||
|
.Where(item => item.PlanCode == subscription.PlanCode)
|
||||||
|
.Select(item => item.ModuleCode)
|
||||||
|
.Distinct()
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
if (moduleCodes.Length == 0)
|
||||||
|
{
|
||||||
|
moduleCodes = ["*"];
|
||||||
|
}
|
||||||
|
var eventPublisher = provider.GetRequiredService<ISecurityEventPublisher>();
|
||||||
|
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
foreach (var moduleCode in moduleCodes)
|
||||||
|
{
|
||||||
|
await eventPublisher.CapabilityChangedAsync(
|
||||||
|
command.TenantId,
|
||||||
|
moduleCode,
|
||||||
|
"subscription_changed",
|
||||||
|
version,
|
||||||
|
$"tenant-subscription-{subscription.Id:N}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return ToSubscriptionItem(subscription);
|
return ToSubscriptionItem(subscription);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
@@ -719,11 +753,23 @@ internal sealed class PlatformAdminService(
|
|||||||
string reason,
|
string reason,
|
||||||
Func<TikuDbContext, Task<TResult>> operation,
|
Func<TikuDbContext, Task<TResult>> operation,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<TResult> ExecuteSystemAsync<TResult>(
|
||||||
|
string reason,
|
||||||
|
Func<IServiceProvider, TikuDbContext, Task<TResult>> operation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return tenantExecutionScope.ExecuteAsync(
|
return tenantExecutionScope.ExecuteAsync(
|
||||||
null,
|
new SystemScopeRequest(
|
||||||
reason,
|
null,
|
||||||
async (provider, _) => await operation(provider.GetRequiredService<TikuDbContext>()),
|
SystemScopeCallerType.Platform,
|
||||||
|
nameof(PlatformAdminService),
|
||||||
|
reason,
|
||||||
|
Guid.NewGuid().ToString("N")),
|
||||||
|
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ public sealed class QuestionBankQueryService(
|
|||||||
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
||||||
? []
|
? []
|
||||||
: await tenantExecutionScope.ExecuteAsync(
|
: await tenantExecutionScope.ExecuteAsync(
|
||||||
filter.TenantId,
|
new SystemScopeRequest(
|
||||||
"List platform question banks for an entitled tenant",
|
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||||
|
"List platform question banks for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
@@ -336,8 +337,9 @@ public sealed class QuestionBankQueryService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return tenantExecutionScope.ExecuteAsync(
|
return tenantExecutionScope.ExecuteAsync(
|
||||||
filter.TenantId,
|
new SystemScopeRequest(
|
||||||
"List platform questions for an entitled tenant",
|
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||||
|
"List platform questions for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
@@ -380,8 +382,9 @@ public sealed class QuestionBankQueryService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return tenantExecutionScope.ExecuteAsync(
|
return tenantExecutionScope.ExecuteAsync(
|
||||||
tenantId,
|
new SystemScopeRequest(
|
||||||
"Read platform question versions for an entitled tenant",
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||||
|
"Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ public sealed class QuestionReferenceService(
|
|||||||
{
|
{
|
||||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||||
var ownerTenantId = await tenantExecutionScope.ExecuteAsync(
|
var ownerTenantId = await tenantExecutionScope.ExecuteAsync(
|
||||||
tenantId,
|
new SystemScopeRequest(
|
||||||
"Resolve a platform question for an entitled tenant",
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionReferenceService),
|
||||||
|
"Resolve a platform question for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||||
async (provider, token) =>
|
async (provider, token) =>
|
||||||
{
|
{
|
||||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
|
|||||||
87
Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs
Normal file
87
Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
|
using Tiku.Domain.Platform;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
|
internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapabilityAccessEvaluator
|
||||||
|
{
|
||||||
|
public async Task<bool> IsAllowedAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string moduleCode,
|
||||||
|
CapabilityOperation operation,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalized = moduleCode.Trim().ToLowerInvariant();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantActive = await dbContext.Tenants.AsNoTracking()
|
||||||
|
.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken);
|
||||||
|
if (!tenantActive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var overrideMode = await dbContext.TenantModuleOverrides.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId && item.ModuleCode == normalized &&
|
||||||
|
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||||
|
.Select(item => (TenantModuleOverrideMode?)item.Mode)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
if (overrideMode == TenantModuleOverrideMode.Disabled)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = await dbContext.TenantSubscriptions.AsNoTracking()
|
||||||
|
.Where(item => item.TenantId == tenantId)
|
||||||
|
.OrderByDescending(item => item.UpdatedAt)
|
||||||
|
.Select(item => new { item.PlanCode, item.Status, item.StartsAt, item.ExpiresAt })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (subscription is null || subscription.StartsAt > now || subscription.ExpiresAt <= now)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entitled = overrideMode == TenantModuleOverrideMode.Enabled ||
|
||||||
|
await dbContext.PlanModuleEntitlements.AsNoTracking().AnyAsync(
|
||||||
|
item => item.PlanCode == subscription.PlanCode && item.ModuleCode == normalized && item.Enabled,
|
||||||
|
cancellationToken);
|
||||||
|
if (!entitled)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return operation == CapabilityOperation.Read ||
|
||||||
|
subscription.Status is TenantSubscriptionStatus.Trial or TenantSubscriptionStatus.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CapabilityOperation operation = CapabilityOperation.Read,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var modules = await dbContext.ProductModules.AsNoTracking()
|
||||||
|
.Where(item => item.Status == ProductModuleStatus.Active)
|
||||||
|
.Select(item => item.Code)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var enabled = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var module in modules)
|
||||||
|
{
|
||||||
|
if (await IsAllowedAsync(tenantId, module, operation, cancellationToken))
|
||||||
|
{
|
||||||
|
enabled.Add(module);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
|
public sealed class RedisSecurityConnectionOptions
|
||||||
|
{
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
135
Tiku.Infrastructure/Security/RedisSecurityStore.cs
Normal file
135
Tiku.Infrastructure/Security/RedisSecurityStore.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
|
internal sealed class RedisSecurityStore(
|
||||||
|
IConnectionMultiplexer connection,
|
||||||
|
string environmentName,
|
||||||
|
ILogger<RedisSecurityStore> logger) : IRedisSecurityStore
|
||||||
|
{
|
||||||
|
private static readonly Meter Meter = new("Tiku.Security.Redis", "1.0.0");
|
||||||
|
private static readonly Counter<long> OperationCounter = Meter.CreateCounter<long>("tiku.redis.security.operations");
|
||||||
|
private static readonly Counter<long> RejectionCounter = Meter.CreateCounter<long>("tiku.redis.rate_limit.rejections");
|
||||||
|
private static readonly Counter<long> ErrorCounter = Meter.CreateCounter<long>("tiku.redis.security.errors");
|
||||||
|
private static readonly Histogram<double> ScriptDuration = Meter.CreateHistogram<double>(
|
||||||
|
"tiku.redis.lua.duration", "ms");
|
||||||
|
private const string ConsumeScript = """
|
||||||
|
local now = redis.call('TIME')
|
||||||
|
local nowMs = now[1] * 1000 + math.floor(now[2] / 1000)
|
||||||
|
local retryAfter = 0
|
||||||
|
for i = 1, #KEYS do
|
||||||
|
local current = tonumber(redis.call('GET', KEYS[i]) or '0')
|
||||||
|
local limit = tonumber(ARGV[(i - 1) * 2 + 1])
|
||||||
|
if current >= limit then
|
||||||
|
local ttl = redis.call('PTTL', KEYS[i])
|
||||||
|
if ttl > retryAfter then retryAfter = ttl end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if retryAfter > 0 then return {0, retryAfter} end
|
||||||
|
for i = 1, #KEYS do
|
||||||
|
local window = tonumber(ARGV[(i - 1) * 2 + 2])
|
||||||
|
local value = redis.call('INCR', KEYS[i])
|
||||||
|
if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end
|
||||||
|
end
|
||||||
|
return {1, 0}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly string prefix = $"tiku:{Normalize(environmentName)}";
|
||||||
|
|
||||||
|
public bool IsConfigured => true;
|
||||||
|
|
||||||
|
public async Task<DistributedRateLimitResult> ConsumeAsync(
|
||||||
|
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (buckets.Count == 0)
|
||||||
|
{
|
||||||
|
return new DistributedRateLimitResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var started = Stopwatch.GetTimestamp();
|
||||||
|
var keys = buckets.Select(bucket => (RedisKey)$"{prefix}:rl:{bucket.Key}").ToArray();
|
||||||
|
var values = buckets
|
||||||
|
.SelectMany(bucket => new RedisValue[] { bucket.PermitLimit, (long)bucket.Window.TotalMilliseconds })
|
||||||
|
.ToArray();
|
||||||
|
var result = (RedisResult[])(await connection.GetDatabase()
|
||||||
|
.ScriptEvaluateAsync(ConsumeScript, keys, values).WaitAsync(cancellationToken))!;
|
||||||
|
var allowed = (long)result[0] == 1;
|
||||||
|
var retryMs = (long)result[1];
|
||||||
|
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
||||||
|
ScriptDuration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||||
|
if (!allowed)
|
||||||
|
{
|
||||||
|
RejectionCounter.Add(1);
|
||||||
|
}
|
||||||
|
return new DistributedRateLimitResult(
|
||||||
|
allowed,
|
||||||
|
retryMs > 0 ? TimeSpan.FromMilliseconds(retryMs) : null);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||||
|
{
|
||||||
|
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
||||||
|
logger.LogError(exception, "Redis security operation failed closed.");
|
||||||
|
throw new RedisSecurityUnavailableException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> PingAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.GetDatabase().PingAsync().WaitAsync(cancellationToken);
|
||||||
|
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||||
|
{
|
||||||
|
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetInvalidationVersionAsync(
|
||||||
|
string realm,
|
||||||
|
Guid? tenantId,
|
||||||
|
Guid? userId,
|
||||||
|
long version,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var key = $"{prefix}:auth-inv:{Normalize(realm)}:{tenantId?.ToString("N") ?? "-"}:{userId?.ToString("N") ?? "-"}";
|
||||||
|
await connection.GetDatabase().StringSetAsync(key, version, TimeSpan.FromDays(2)).WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string value) =>
|
||||||
|
string.Concat(value.Trim().ToLowerInvariant().Select(character =>
|
||||||
|
char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NullRedisSecurityStore : IRedisSecurityStore
|
||||||
|
{
|
||||||
|
public bool IsConfigured => false;
|
||||||
|
|
||||||
|
public Task<DistributedRateLimitResult> ConsumeAsync(
|
||||||
|
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(new DistributedRateLimitResult(true));
|
||||||
|
|
||||||
|
public Task<bool> PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||||
|
|
||||||
|
public Task SetInvalidationVersionAsync(
|
||||||
|
string realm,
|
||||||
|
Guid? tenantId,
|
||||||
|
Guid? userId,
|
||||||
|
long version,
|
||||||
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RedisSecurityUnavailableException(Exception innerException)
|
||||||
|
: Exception("Redis security services are unavailable.", innerException);
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Tenancy;
|
namespace Tiku.Infrastructure.Tenancy;
|
||||||
|
|
||||||
@@ -9,42 +14,102 @@ public sealed class TenantExecutionScope(
|
|||||||
ILogger<TenantExecutionScope> logger) : ITenantExecutionScope
|
ILogger<TenantExecutionScope> logger) : ITenantExecutionScope
|
||||||
{
|
{
|
||||||
public Task ExecuteAsync(
|
public Task ExecuteAsync(
|
||||||
Guid? targetTenantId,
|
SystemScopeRequest request,
|
||||||
string reason,
|
|
||||||
Func<IServiceProvider, CancellationToken, Task> operation,
|
Func<IServiceProvider, CancellationToken, Task> operation,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default) =>
|
||||||
{
|
ExecuteAsync<object?>(request, async (provider, token) =>
|
||||||
return ExecuteAsync<object?>(
|
{
|
||||||
targetTenantId,
|
await operation(provider, token);
|
||||||
reason,
|
return null;
|
||||||
async (provider, token) =>
|
}, cancellationToken);
|
||||||
{
|
|
||||||
await operation(provider, token);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TResult> ExecuteAsync<TResult>(
|
public async Task<TResult> ExecuteAsync<TResult>(
|
||||||
Guid? targetTenantId,
|
SystemScopeRequest request,
|
||||||
string reason,
|
|
||||||
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(operation);
|
Validate(request, operation);
|
||||||
if (string.IsNullOrWhiteSpace(reason))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var initializer = scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>();
|
var initializer = scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>();
|
||||||
initializer.InitializeSystem(targetTenantId, reason);
|
initializer.InitializeSystem(request.TargetTenantId, request.Reason);
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Entering audited system tenant scope. TargetTenantId={TargetTenantId} Reason={Reason}",
|
"Entering audited system scope. CallerType={CallerType} Caller={Caller} TargetTenantId={TargetTenantId} CorrelationId={CorrelationId} Reason={Reason}",
|
||||||
targetTenantId,
|
request.CallerType,
|
||||||
reason);
|
request.Caller,
|
||||||
|
request.TargetTenantId,
|
||||||
|
request.CorrelationId,
|
||||||
|
request.Reason);
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var started = DateTimeOffset.UtcNow;
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
await using var transaction = dbContext.Database.IsRelational()
|
||||||
|
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
await WriteAuditAsync(dbContext, request, "system_scope.entered", started, null, null, cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await operation(scope.ServiceProvider, cancellationToken);
|
||||||
|
await WriteAuditAsync(
|
||||||
|
dbContext, request, "system_scope.completed", started, stopwatch.ElapsedMilliseconds, null, cancellationToken);
|
||||||
|
if (transaction is not null)
|
||||||
|
{
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
if (transaction is not null)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(CancellationToken.None);
|
||||||
|
dbContext.ChangeTracker.Clear();
|
||||||
|
await WriteAuditAsync(
|
||||||
|
dbContext, request, "system_scope.entered", started, null, null, CancellationToken.None);
|
||||||
|
}
|
||||||
|
await WriteAuditAsync(
|
||||||
|
dbContext, request, "system_scope.failed", started, stopwatch.ElapsedMilliseconds,
|
||||||
|
exception.GetType().Name, CancellationToken.None);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await operation(scope.ServiceProvider, cancellationToken);
|
private static void Validate<TResult>(
|
||||||
|
SystemScopeRequest request,
|
||||||
|
Func<IServiceProvider, CancellationToken, Task<TResult>> operation)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentNullException.ThrowIfNull(operation);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.Caller);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteAuditAsync(
|
||||||
|
TikuDbContext dbContext,
|
||||||
|
SystemScopeRequest request,
|
||||||
|
string action,
|
||||||
|
DateTimeOffset startedAt,
|
||||||
|
long? elapsedMilliseconds,
|
||||||
|
string? failureType,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
dbContext.AuditLogs.Add(new AuditLog
|
||||||
|
{
|
||||||
|
TenantId = request.TargetTenantId,
|
||||||
|
Action = action,
|
||||||
|
TargetType = "system_scope",
|
||||||
|
TargetId = request.CorrelationId,
|
||||||
|
Details = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
callerType = request.CallerType.ToString(),
|
||||||
|
request.Caller,
|
||||||
|
request.Reason,
|
||||||
|
request.CorrelationId,
|
||||||
|
startedAt,
|
||||||
|
elapsedMilliseconds,
|
||||||
|
failureType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using Tiku.Domain.Operations;
|
|||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
using Tiku.Infrastructure.Security;
|
using Tiku.Infrastructure.Security;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||||||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||||||
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
||||||
@@ -28,7 +29,8 @@ public sealed class TenantAdminDirectService(
|
|||||||
ITenantExternalProviderConfigService providerConfigService,
|
ITenantExternalProviderConfigService providerConfigService,
|
||||||
INotificationProvider notificationProvider,
|
INotificationProvider notificationProvider,
|
||||||
ICurrentAccessContext currentAccessContext,
|
ICurrentAccessContext currentAccessContext,
|
||||||
IAuthSessionStore sessionStore) : ITenantAdminDirectService
|
IAuthSessionStore sessionStore,
|
||||||
|
ISecurityEventPublisher securityEventPublisher) : ITenantAdminDirectService
|
||||||
{
|
{
|
||||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||||
TenantAdminActor actor,
|
TenantAdminActor actor,
|
||||||
@@ -1154,6 +1156,7 @@ public sealed class TenantAdminDirectService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isNew = membership is null;
|
var isNew = membership is null;
|
||||||
|
var previousStatus = membership?.Status.ToString() ?? "none";
|
||||||
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
|
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
|
||||||
{
|
{
|
||||||
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
|
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
|
||||||
@@ -1182,6 +1185,9 @@ public sealed class TenantAdminDirectService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
|
await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
|
||||||
|
await securityEventPublisher.MembershipChangedAsync(
|
||||||
|
actor.TenantId, user.Id, previousStatus, status.ToString(),
|
||||||
|
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||||
}
|
}
|
||||||
@@ -1206,9 +1212,13 @@ public sealed class TenantAdminDirectService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
|
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
|
||||||
|
var previousStatus = membership.Status;
|
||||||
membership.Status = MembershipStatus.Disabled;
|
membership.Status = MembershipStatus.Disabled;
|
||||||
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
|
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
|
||||||
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
|
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
|
||||||
|
await securityEventPublisher.MembershipChangedAsync(
|
||||||
|
actor.TenantId, membership.UserId, previousStatus.ToString(), MembershipStatus.Disabled.ToString(),
|
||||||
|
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
||||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Tiku.Contracts\Tiku.Contracts.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -16,6 +17,11 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" />
|
||||||
|
<PackageReference Include="MassTransit" />
|
||||||
|
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||||
|
<PackageReference Include="MassTransit.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.SemanticKernel" />
|
<PackageReference Include="Microsoft.SemanticKernel" />
|
||||||
<PackageReference Include="Npgsql" />
|
<PackageReference Include="Npgsql" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
|||||||
@@ -34,8 +34,17 @@ public sealed class ApiTestFactory(
|
|||||||
{
|
{
|
||||||
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||||
|
|
||||||
|
public string DatabaseConnectionString => database.ConnectionString;
|
||||||
|
|
||||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
|
if (configurationOverrides is not null)
|
||||||
|
{
|
||||||
|
foreach (var pair in configurationOverrides.Where(pair => pair.Value is not null))
|
||||||
|
{
|
||||||
|
builder.UseSetting(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||||
{
|
{
|
||||||
var values = new Dictionary<string, string?>
|
var values = new Dictionary<string, string?>
|
||||||
@@ -61,7 +70,7 @@ public sealed class ApiTestFactory(
|
|||||||
descriptor.ServiceType == typeof(NpgsqlDataSource) ||
|
descriptor.ServiceType == typeof(NpgsqlDataSource) ||
|
||||||
descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) ||
|
descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) ||
|
||||||
descriptor.ServiceType == typeof(DbContextOptions<TikuDbContext>) ||
|
descriptor.ServiceType == typeof(DbContextOptions<TikuDbContext>) ||
|
||||||
descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true)
|
descriptor.ServiceType == typeof(TikuDbContext))
|
||||||
.ToArray())
|
.ToArray())
|
||||||
{
|
{
|
||||||
services.Remove(descriptor);
|
services.Remove(descriptor);
|
||||||
|
|||||||
@@ -305,6 +305,123 @@ public sealed class AuthEndpointTests
|
|||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wechat_first_login_is_rejected_without_self_registration_and_leaves_no_identity()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await SeedWechatProviderAsync(factory, tenantId,
|
||||||
|
new TenantAuthPolicy
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
AllowExternalStudentSelfRegistration = false
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
"/api/auth/oauth/wechat-miniapp",
|
||||||
|
new OAuthCodeDto
|
||||||
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
|
TenantCode = tenantId.ToString("N"),
|
||||||
|
Code = "wx-code"
|
||||||
|
});
|
||||||
|
using var scope = factory.CreateSystemScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.DoesNotContain(dbContext.UserIdentities, item => item.Provider == "wechat_miniapp");
|
||||||
|
Assert.DoesNotContain(dbContext.TenantMemberships, item => item.TenantId == tenantId);
|
||||||
|
Assert.DoesNotContain(dbContext.AuthSessions, item => item.TenantId == tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MembershipStatus.Invited)]
|
||||||
|
[InlineData(MembershipStatus.Disabled)]
|
||||||
|
public async Task Wechat_login_does_not_reactivate_non_active_membership(MembershipStatus status)
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var user = new User { Id = Guid.NewGuid(), Name = "Existing Wechat User" };
|
||||||
|
await SeedWechatProviderAsync(factory, tenantId,
|
||||||
|
user,
|
||||||
|
new UserIdentity
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
Provider = "wechat_miniapp",
|
||||||
|
ProviderSubject = "wx-app-id:mini-open-id",
|
||||||
|
OpenId = "mini-open-id",
|
||||||
|
UnionId = "union-id"
|
||||||
|
},
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = user.Id,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
Status = status
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
"/api/auth/oauth/wechat-miniapp",
|
||||||
|
new OAuthCodeDto
|
||||||
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
|
TenantCode = tenantId.ToString("N"),
|
||||||
|
Code = "wx-code"
|
||||||
|
});
|
||||||
|
using var scope = factory.CreateSystemScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Equal(status, dbContext.TenantMemberships.Single(item =>
|
||||||
|
item.TenantId == tenantId && item.UserId == user.Id).Status);
|
||||||
|
Assert.DoesNotContain(dbContext.AuthSessions, item =>
|
||||||
|
item.TenantId == tenantId && item.UserId == user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedWechatProviderAsync(
|
||||||
|
ApiTestFactory factory,
|
||||||
|
Guid tenantId,
|
||||||
|
params object[] additionalEntities)
|
||||||
|
{
|
||||||
|
const string secretRef = "tenant_secrets:identity:wechat_miniapp:default";
|
||||||
|
var protectedSecret = ProtectTenantSecret(
|
||||||
|
tenantId,
|
||||||
|
secretRef,
|
||||||
|
JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" }));
|
||||||
|
var entities = new List<object>
|
||||||
|
{
|
||||||
|
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Wechat Tenant" },
|
||||||
|
new TenantExternalProvider
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Provider = "wechat_miniapp",
|
||||||
|
Capability = TenantExternalProviderCapability.Identity,
|
||||||
|
Status = TenantExternalProviderStatus.Active,
|
||||||
|
SecretRef = secretRef,
|
||||||
|
ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-app-id" })
|
||||||
|
},
|
||||||
|
new TenantSecret
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Purpose = "identity",
|
||||||
|
Provider = "wechat_miniapp",
|
||||||
|
SecretKey = "default",
|
||||||
|
SecretRef = secretRef,
|
||||||
|
Status = TenantSecretStatus.Active,
|
||||||
|
EncryptionKeyId = protectedSecret.KeyId,
|
||||||
|
EncryptedPayload = protectedSecret.Ciphertext,
|
||||||
|
EncryptionNonce = protectedSecret.Nonce,
|
||||||
|
EncryptionTag = protectedSecret.Tag
|
||||||
|
}
|
||||||
|
};
|
||||||
|
entities.AddRange(additionalEntities);
|
||||||
|
await factory.SeedAsync(entities.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
||||||
ApiTestFactory factory)
|
ApiTestFactory factory)
|
||||||
{
|
{
|
||||||
|
|||||||
78
Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs
Normal file
78
Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Routing;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tiku.Api.Security;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class AuthorizationManifestTests
|
||||||
|
{
|
||||||
|
private const int ExpectedActionCount = 330;
|
||||||
|
private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||||
|
{
|
||||||
|
var descriptors = typeof(Tiku.Api.ApiProgramMarker).Assembly.GetTypes()
|
||||||
|
.Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type))
|
||||||
|
.SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
|
||||||
|
.Where(method => method.GetCustomAttributes<HttpMethodAttribute>().Any())
|
||||||
|
.Select(method => Describe(type, method)))
|
||||||
|
.OrderBy(value => value, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', descriptors))))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
descriptors.Length == ExpectedActionCount && hash == ExpectedSha256,
|
||||||
|
$"Authorization manifest changed. count={descriptors.Length}, sha256={hash}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Runtime_controller_endpoints_have_authorization_and_audit_metadata()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
_ = await client.GetAsync("/api/health");
|
||||||
|
var endpoints = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
|
||||||
|
.Where(endpoint => endpoint.Metadata.GetMetadata<ControllerActionDescriptor>() is not null)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.NotEmpty(endpoints);
|
||||||
|
foreach (var endpoint in endpoints)
|
||||||
|
{
|
||||||
|
var anonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
|
||||||
|
var metadata = endpoint.Metadata.GetMetadata<EndpointAuthorizationMetadata>();
|
||||||
|
if (anonymous)
|
||||||
|
{
|
||||||
|
Assert.Null(metadata);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction));
|
||||||
|
Assert.Contains(metadata.Realm, new[] { "authenticated", "tenant", "platform" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe(Type controller, MethodInfo action)
|
||||||
|
{
|
||||||
|
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;
|
||||||
|
var http = action.GetCustomAttributes<HttpMethodAttribute>().ToArray();
|
||||||
|
var methods = string.Join(',', http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal));
|
||||||
|
var templates = string.Join(',', http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal));
|
||||||
|
var policies = controller.GetCustomAttributes<AuthorizeAttribute>()
|
||||||
|
.Concat(action.GetCustomAttributes<AuthorizeAttribute>())
|
||||||
|
.Select(attribute => attribute.Policy ?? "authenticated")
|
||||||
|
.Order(StringComparer.Ordinal);
|
||||||
|
var anonymous = controller.IsDefined(typeof(AllowAnonymousAttribute)) ||
|
||||||
|
action.IsDefined(typeof(AllowAnonymousAttribute));
|
||||||
|
return $"{methods}|{controllerRoute}/{templates}|{controller.Name}.{action.Name}|anonymous={anonymous}|policies={string.Join(',', policies)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
183
Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs
Normal file
183
Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Api.Options;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Auth;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class BrowserAuthenticationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_login_uses_secure_cookies_and_does_not_return_tokens()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
const string phone = "13890000000";
|
||||||
|
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Browser User" }.WithTestPassword();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Browser Tenant" },
|
||||||
|
user,
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = user.Id,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||||
|
{
|
||||||
|
HandleCookies = false
|
||||||
|
});
|
||||||
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||||
|
client.DefaultRequestHeaders.Add("Origin", "http://localhost");
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/api/browser-auth/login/password", new PasswordLoginDto
|
||||||
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
|
TenantCode = tenantId.ToString("N"),
|
||||||
|
Identifier = phone,
|
||||||
|
Password = PasswordTestUserExtensions.TestPassword
|
||||||
|
});
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
using var json = JsonDocument.Parse(body);
|
||||||
|
var cookies = response.Headers.GetValues("Set-Cookie").ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.DoesNotContain("accessToken", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("refreshToken", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal("Authenticated", json.RootElement.GetProperty("status").GetString());
|
||||||
|
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.AccessCookie) &&
|
||||||
|
value.Contains("secure", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
value.Contains("httponly", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.RefreshCookie) &&
|
||||||
|
value.Contains("samesite=strict", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.CsrfCookie));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Browser_refresh_requires_same_origin_and_matching_csrf_token()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
const string phone = "13890000001";
|
||||||
|
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "CSRF User" }.WithTestPassword();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CSRF Tenant" },
|
||||||
|
user,
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = user.Id,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||||
|
{
|
||||||
|
HandleCookies = false
|
||||||
|
});
|
||||||
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||||
|
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new PasswordLoginDto
|
||||||
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
|
TenantCode = tenantId.ToString("N"),
|
||||||
|
Identifier = phone,
|
||||||
|
Password = PasswordTestUserExtensions.TestPassword
|
||||||
|
})
|
||||||
|
};
|
||||||
|
login.Headers.Add("Origin", "http://localhost");
|
||||||
|
var loginResponse = await client.SendAsync(login);
|
||||||
|
var setCookies = loginResponse.Headers.GetValues("Set-Cookie").ToArray();
|
||||||
|
var refresh = ReadCookie(setCookies, BrowserAuthOptions.RefreshCookie);
|
||||||
|
var csrf = ReadCookie(setCookies, BrowserAuthOptions.CsrfCookie);
|
||||||
|
var cookieHeader = $"{BrowserAuthOptions.RefreshCookie}={refresh}; {BrowserAuthOptions.CsrfCookie}={csrf}";
|
||||||
|
|
||||||
|
var missingCsrf = await SendRefreshAsync(client, cookieHeader, "http://localhost", null);
|
||||||
|
var wrongOrigin = await SendRefreshAsync(client, cookieHeader, "https://attacker.example", csrf);
|
||||||
|
var accepted = await SendRefreshAsync(client, cookieHeader, "http://localhost", csrf);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, missingCsrf.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, wrongOrigin.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||||
|
Assert.Contains(accepted.Headers.GetValues("Set-Cookie"), value =>
|
||||||
|
value.StartsWith(BrowserAuthOptions.RefreshCookie, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Access_cookie_is_ignored_without_same_origin_evidence()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
const string phone = "13890000002";
|
||||||
|
var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Cookie User" }.WithTestPassword();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Cookie Tenant" },
|
||||||
|
user,
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = user.Id,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||||
|
{
|
||||||
|
HandleCookies = false
|
||||||
|
});
|
||||||
|
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||||
|
using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new PasswordLoginDto
|
||||||
|
{
|
||||||
|
Realm = AuthRealm.Tenant,
|
||||||
|
TenantCode = tenantId.ToString("N"),
|
||||||
|
Identifier = phone,
|
||||||
|
Password = PasswordTestUserExtensions.TestPassword
|
||||||
|
})
|
||||||
|
};
|
||||||
|
login.Headers.Add("Origin", "http://localhost");
|
||||||
|
var loginResponse = await client.SendAsync(login);
|
||||||
|
var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), BrowserAuthOptions.AccessCookie);
|
||||||
|
|
||||||
|
using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||||
|
crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
|
||||||
|
crossSite.Headers.Add("Sec-Fetch-Site", "cross-site");
|
||||||
|
var rejected = await client.SendAsync(crossSite);
|
||||||
|
using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||||
|
sameOrigin.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}");
|
||||||
|
sameOrigin.Headers.Add("Origin", "http://localhost");
|
||||||
|
var accepted = await client.SendAsync(sameOrigin);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpResponseMessage> SendRefreshAsync(
|
||||||
|
HttpClient client,
|
||||||
|
string cookieHeader,
|
||||||
|
string origin,
|
||||||
|
string? csrf)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/refresh");
|
||||||
|
request.Headers.Add("Cookie", cookieHeader);
|
||||||
|
request.Headers.Add("Origin", origin);
|
||||||
|
if (csrf is not null)
|
||||||
|
{
|
||||||
|
request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf);
|
||||||
|
}
|
||||||
|
return await client.SendAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadCookie(IEnumerable<string> setCookies, string name)
|
||||||
|
{
|
||||||
|
var prefix = name + "=";
|
||||||
|
var header = setCookies.Single(value => value.StartsWith(prefix, StringComparison.Ordinal));
|
||||||
|
return header[prefix.Length..header.IndexOf(';')];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs
Normal file
49
Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
|
using Tiku.Domain.Platform;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class CapabilityAuthorizationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
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,
|
||||||
|
PlanCode = "capability-test",
|
||||||
|
Status = TenantSubscriptionStatus.Active,
|
||||||
|
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||||
|
});
|
||||||
|
|
||||||
|
using var scope = factory.CreateSystemScope("Verify capability authorization");
|
||||||
|
var evaluator = scope.ServiceProvider.GetRequiredService<ICapabilityAccessEvaluator>();
|
||||||
|
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
|
||||||
|
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
|
||||||
|
{
|
||||||
|
PlanCode = "capability-test",
|
||||||
|
ModuleCode = "content"
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
|
||||||
|
|
||||||
|
var subscription = dbContext.TenantSubscriptions.Single(item => item.TenantId == tenantId);
|
||||||
|
subscription.Status = TenantSubscriptionStatus.PastDue;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read));
|
||||||
|
Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,4 +85,24 @@ public sealed class ProductionConfigurationTests
|
|||||||
MasterKey = Convert.ToBase64String(new byte[32])
|
MasterKey = Convert.ToBase64String(new byte[32])
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Production_network_configuration_requires_formal_hosts_allowed_hosts_and_proxy()
|
||||||
|
{
|
||||||
|
var developmentDefaults = new TenantResolutionOptions();
|
||||||
|
var wildcard = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["AllowedHosts"] = "*" })
|
||||||
|
.Build();
|
||||||
|
Assert.False(OptionsValidation.BeValidTenantResolutionOptions(developmentDefaults, wildcard, true));
|
||||||
|
|
||||||
|
var production = new TenantResolutionOptions
|
||||||
|
{
|
||||||
|
PlatformHosts = ["admin.example.com"],
|
||||||
|
TrustedProxyAddresses = ["10.0.0.10"]
|
||||||
|
};
|
||||||
|
var explicitHosts = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["AllowedHosts"] = "admin.example.com;api.example.com" })
|
||||||
|
.Build();
|
||||||
|
Assert.True(OptionsValidation.BeValidTenantResolutionOptions(production, explicitHosts, true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
217
Tiku.IntegrationTests/MassTransitOutboxTests.cs
Normal file
217
Tiku.IntegrationTests/MassTransitOutboxTests.cs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using MassTransit.EntityFrameworkCoreIntegration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Application;
|
||||||
|
using Tiku.Contracts;
|
||||||
|
using Tiku.Infrastructure;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class MassTransitOutboxTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version()
|
||||||
|
{
|
||||||
|
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
|
||||||
|
var redisConnection = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
||||||
|
if (string.IsNullOrWhiteSpace(rabbitMqHost) || string.IsNullOrWhiteSpace(redisConnection))
|
||||||
|
{
|
||||||
|
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 redisEnvironment = $"consumer-{Guid.NewGuid():N}";
|
||||||
|
var workerBuilder = Host.CreateApplicationBuilder();
|
||||||
|
workerBuilder.Services.AddApplication();
|
||||||
|
workerBuilder.Services.AddInfrastructure(factory.DatabaseConnectionString);
|
||||||
|
workerBuilder.Services.AddRedisSecurity(redisConnection, redisEnvironment);
|
||||||
|
workerBuilder.Services.AddReliableMessaging(CreateRabbitOptions(rabbitMqHost, configureConsumers: true));
|
||||||
|
using var worker = workerBuilder.Build();
|
||||||
|
await worker.StartAsync();
|
||||||
|
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var messageId = Guid.NewGuid();
|
||||||
|
const long version = 123456789;
|
||||||
|
using (var scope = factory.CreateSystemScope("Publish duplicate inbox test message"))
|
||||||
|
{
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var publishEndpoint = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||||
|
var message = new AuthorizationStateChangedV1(
|
||||||
|
Guid.NewGuid(), tenantId, userId, "consumer_test", version,
|
||||||
|
DateTimeOffset.UtcNow, Guid.NewGuid().ToString("N"));
|
||||||
|
await publishEndpoint.Publish(message, context => context.MessageId = messageId);
|
||||||
|
await publishEndpoint.Publish(message, context => context.MessageId = messageId);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var redisKey = $"tiku:{redisEnvironment}:auth-inv:authorization:{tenantId:N}:{userId:N}";
|
||||||
|
var consumed = false;
|
||||||
|
for (var attempt = 0; attempt < 60; attempt++)
|
||||||
|
{
|
||||||
|
var multiplexer = worker.Services.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
if (await multiplexer.GetDatabase().StringGetAsync(redisKey) == version)
|
||||||
|
{
|
||||||
|
consumed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Task.Delay(250);
|
||||||
|
}
|
||||||
|
Assert.True(consumed, "Worker did not consume the security event within 15 seconds.");
|
||||||
|
|
||||||
|
using (var verification = factory.CreateSystemScope("Verify duplicate consumer inbox"))
|
||||||
|
{
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.Single(await dbContext.Set<InboxState>()
|
||||||
|
.Where(item => item.MessageId == messageId)
|
||||||
|
.ToArrayAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
var managementEndpoint = ResolveRabbitManagementEndpoint(rabbitMqHost);
|
||||||
|
if (managementEndpoint is not null)
|
||||||
|
{
|
||||||
|
Assert.Equal(0, await GetQueueMessageCountAsync(
|
||||||
|
managementEndpoint, "security-state-changed_error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var redis = worker.Services.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
await redis.GetDatabase().KeyDeleteAsync(redisKey);
|
||||||
|
await worker.StopAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RabbitMq_health_and_bus_outbox_follow_database_transaction()
|
||||||
|
{
|
||||||
|
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 ready = await client.GetAsync("/api/health/ready");
|
||||||
|
using var readyJson = JsonDocument.Parse(await ready.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, ready.StatusCode);
|
||||||
|
Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("configured").GetBoolean());
|
||||||
|
Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("ready").GetBoolean());
|
||||||
|
|
||||||
|
using (var rollbackScope = factory.CreateSystemScope("Verify rolled back bus outbox"))
|
||||||
|
{
|
||||||
|
var dbContext = rollbackScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var publisher = rollbackScope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||||
|
await publisher.AuthorizationChangedAsync(
|
||||||
|
null, null, "rollback_test", 1, Guid.NewGuid().ToString("N"));
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var verification = factory.CreateSystemScope("Verify rolled back outbox is empty"))
|
||||||
|
{
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.Empty(await dbContext.Set<OutboxMessage>().ToArrayAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var commitScope = factory.CreateSystemScope("Verify committed bus outbox"))
|
||||||
|
{
|
||||||
|
var dbContext = commitScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var publisher = commitScope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
|
||||||
|
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||||
|
await publisher.AuthorizationChangedAsync(
|
||||||
|
null, null, "commit_test", 2, Guid.NewGuid().ToString("N"));
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var drained = false;
|
||||||
|
for (var attempt = 0; attempt < 40; attempt++)
|
||||||
|
{
|
||||||
|
using var verification = factory.CreateSystemScope("Wait for committed outbox delivery");
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
if (!await dbContext.Set<OutboxMessage>().AnyAsync())
|
||||||
|
{
|
||||||
|
drained = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Task.Delay(250);
|
||||||
|
}
|
||||||
|
if (!drained)
|
||||||
|
{
|
||||||
|
using var diagnostics = factory.CreateSystemScope("Inspect undelivered outbox");
|
||||||
|
var dbContext = diagnostics.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var messages = await dbContext.Set<OutboxMessage>().CountAsync();
|
||||||
|
var states = await dbContext.Set<OutboxState>().CountAsync();
|
||||||
|
Assert.Fail($"Committed MassTransit outbox was not delivered within 10 seconds. messages={messages}, states={states}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Api.ApiTestFactory CreateRabbitFactory(string rabbitMqHost) =>
|
||||||
|
new(configurationOverrides: new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["RabbitMq:Host"] = rabbitMqHost,
|
||||||
|
["RabbitMq:Username"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest",
|
||||||
|
["RabbitMq:Password"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest"
|
||||||
|
});
|
||||||
|
|
||||||
|
private static MessagingOptions CreateRabbitOptions(string rabbitMqHost, bool configureConsumers) => new()
|
||||||
|
{
|
||||||
|
Host = rabbitMqHost,
|
||||||
|
Username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest",
|
||||||
|
Password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest",
|
||||||
|
ConfigureConsumers = configureConsumers
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<bool> WaitForReadyAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 40; attempt++)
|
||||||
|
{
|
||||||
|
if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await Task.Delay(250);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri? ResolveRabbitManagementEndpoint(string rabbitMqHost)
|
||||||
|
{
|
||||||
|
var configured = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_MANAGEMENT");
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured))
|
||||||
|
{
|
||||||
|
return new Uri(configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
var broker = new Uri(rabbitMqHost);
|
||||||
|
return broker.IsLoopback ? new Uri($"http://{broker.Host}:15672") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> GetQueueMessageCountAsync(Uri managementEndpoint, string queueName)
|
||||||
|
{
|
||||||
|
using var client = new HttpClient { BaseAddress = managementEndpoint };
|
||||||
|
var username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest";
|
||||||
|
var password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest";
|
||||||
|
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)}");
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
return document.RootElement.GetProperty("messages").GetInt32();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
Tiku.IntegrationTests/RedisSecurityStoreTests.cs
Normal file
81
Tiku.IntegrationTests/RedisSecurityStoreTests.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Infrastructure;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class RedisSecurityStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Unavailable_redis_fails_closed_for_security_operations()
|
||||||
|
{
|
||||||
|
await using var provider = BuildProvider(
|
||||||
|
"localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false",
|
||||||
|
$"integration-unavailable-{Guid.NewGuid():N}");
|
||||||
|
var store = provider.GetRequiredService<IRedisSecurityStore>();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<RedisSecurityUnavailableException>(() => store.ConsumeAsync(
|
||||||
|
[
|
||||||
|
new DistributedRateLimitBucket("password:ip:test", 1, TimeSpan.FromSeconds(1))
|
||||||
|
]));
|
||||||
|
Assert.False(await store.PingAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Two_instances_share_atomic_limit_and_keys_contain_no_plaintext_identifier()
|
||||||
|
{
|
||||||
|
var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var environment = $"integration-{Guid.NewGuid():N}";
|
||||||
|
await using var first = BuildProvider(connectionString, environment);
|
||||||
|
await using var second = BuildProvider(connectionString, environment);
|
||||||
|
var store1 = first.GetRequiredService<IRedisSecurityStore>();
|
||||||
|
var store2 = second.GetRequiredService<IRedisSecurityStore>();
|
||||||
|
const string phone = "13812345678";
|
||||||
|
var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone))).ToLowerInvariant();
|
||||||
|
var bucket = new DistributedRateLimitBucket(
|
||||||
|
$"sms-verify:tenant-id:login:{phoneHash}", 1, TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attempts = await Task.WhenAll(Enumerable.Range(0, 12).Select(index =>
|
||||||
|
(index & 1) == 0
|
||||||
|
? store1.ConsumeAsync([bucket])
|
||||||
|
: store2.ConsumeAsync([bucket])));
|
||||||
|
|
||||||
|
Assert.Single(attempts, result => result.Allowed);
|
||||||
|
Assert.All(attempts.Where(result => !result.Allowed), result => Assert.NotNull(result.RetryAfter));
|
||||||
|
var multiplexer = first.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single());
|
||||||
|
var keys = server.Keys(pattern: $"tiku:{environment}:*").Select(key => key.ToString()).ToArray();
|
||||||
|
Assert.NotEmpty(keys);
|
||||||
|
Assert.DoesNotContain(keys, key => key.Contains(phone, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
var multiplexer = first.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single());
|
||||||
|
var keys = server.Keys(pattern: $"tiku:{environment}:*").ToArray();
|
||||||
|
if (keys.Length > 0)
|
||||||
|
{
|
||||||
|
await multiplexer.GetDatabase().KeyDeleteAsync(keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider BuildProvider(string connectionString, string environment)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddRedisSecurity(connectionString, environment);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Tiku.IntegrationTests/SystemScopeAuditTests.cs
Normal file
75
Tiku.IntegrationTests/SystemScopeAuditTests.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class SystemScopeAuditTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Descriptor_is_required_and_success_is_audited()
|
||||||
|
{
|
||||||
|
await using var factory = new Api.ApiTestFactory();
|
||||||
|
using var outer = factory.CreateSystemScope("Resolve execution scope");
|
||||||
|
var executionScope = outer.ServiceProvider.GetRequiredService<ITenantExecutionScope>();
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
|
||||||
|
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"),
|
||||||
|
(_, _) => Task.CompletedTask));
|
||||||
|
|
||||||
|
var correlationId = Guid.NewGuid().ToString("N");
|
||||||
|
await executionScope.ExecuteAsync(
|
||||||
|
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
|
||||||
|
(_, _) => Task.CompletedTask);
|
||||||
|
|
||||||
|
using var verification = factory.CreateSystemScope("Verify execution scope audit");
|
||||||
|
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.entered" && item.TargetId == correlationId);
|
||||||
|
Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.completed" && item.TargetId == correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Failed_system_scope_rolls_back_business_write_and_persists_failure_audit()
|
||||||
|
{
|
||||||
|
await using var factory = new Api.ApiTestFactory();
|
||||||
|
using var outer = factory.CreateSystemScope("Resolve execution scope");
|
||||||
|
var executionScope = outer.ServiceProvider.GetRequiredService<ITenantExecutionScope>();
|
||||||
|
var correlationId = Guid.NewGuid().ToString("N");
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(new Tenant
|
||||||
|
{
|
||||||
|
Id = tenantId,
|
||||||
|
Slug = tenantId.ToString("N"),
|
||||||
|
Name = "Existing Tenant"
|
||||||
|
});
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => executionScope.ExecuteAsync(
|
||||||
|
new SystemScopeRequest(
|
||||||
|
tenantId,
|
||||||
|
SystemScopeCallerType.Worker,
|
||||||
|
"background-worker",
|
||||||
|
"verify transactional rollback",
|
||||||
|
correlationId),
|
||||||
|
async (provider, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var dbContext = provider.GetRequiredService<TikuDbContext>();
|
||||||
|
dbContext.TenantBrandings.Add(new TenantBranding
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
BrandName = "Must Roll Back"
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
throw new InvalidOperationException("expected failure");
|
||||||
|
}));
|
||||||
|
|
||||||
|
using var verification = factory.CreateSystemScope("Verify failed execution scope audit");
|
||||||
|
var verificationDb = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.DoesNotContain(verificationDb.TenantBrandings, item => item.TenantId == tenantId);
|
||||||
|
Assert.Contains(verificationDb.AuditLogs, item =>
|
||||||
|
item.Action == "system_scope.entered" && item.TargetId == correlationId);
|
||||||
|
Assert.Contains(verificationDb.AuditLogs, item =>
|
||||||
|
item.Action == "system_scope.failed" && item.TargetId == correlationId);
|
||||||
|
Assert.DoesNotContain(verificationDb.AuditLogs, item =>
|
||||||
|
item.Action == "system_scope.completed" && item.TargetId == correlationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ using Tiku.Application;
|
|||||||
using Tiku.Application.Tenancy;
|
using Tiku.Application.Tenancy;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
using Tiku.Worker;
|
using Tiku.Worker;
|
||||||
|
using Tiku.Infrastructure.Messaging;
|
||||||
|
using Tiku.Infrastructure.Security;
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
var connectionString = builder.Configuration.GetConnectionString("Database")
|
var connectionString = builder.Configuration.GetConnectionString("Database")
|
||||||
@@ -10,6 +12,41 @@ var connectionString = builder.Configuration.GetConnectionString("Database")
|
|||||||
"Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
|
"Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
builder.Services.AddInfrastructure(connectionString);
|
||||||
|
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
|
||||||
|
builder.Services.AddOptions<RedisSecurityConnectionOptions>()
|
||||||
|
.Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty)
|
||||||
|
.Validate(
|
||||||
|
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
|
||||||
|
"Redis is required in Production.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
if (!string.IsNullOrWhiteSpace(redisConnectionString))
|
||||||
|
{
|
||||||
|
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
|
||||||
|
}
|
||||||
|
else if (builder.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
|
||||||
|
}
|
||||||
|
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
|
||||||
|
builder.Services.AddOptions<MessagingOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection("RabbitMq"))
|
||||||
|
.Validate(
|
||||||
|
options => !builder.Environment.IsProduction() ||
|
||||||
|
(options.IsConfigured &&
|
||||||
|
!string.IsNullOrWhiteSpace(options.Username) &&
|
||||||
|
!string.IsNullOrWhiteSpace(options.Password)),
|
||||||
|
"Production RabbitMQ requires a valid Host, Username and Password.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
if (messaging.IsConfigured)
|
||||||
|
{
|
||||||
|
messaging.ConfigureConsumers = true;
|
||||||
|
builder.Services.AddReliableMessaging(messaging);
|
||||||
|
}
|
||||||
|
else if (builder.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host.");
|
||||||
|
}
|
||||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||||
builder.Services.AddHostedService<Worker>();
|
builder.Services.AddHostedService<Worker>();
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"RabbitMq": {
|
||||||
|
"Host": "",
|
||||||
|
"VirtualHost": "/",
|
||||||
|
"Username": "",
|
||||||
|
"Password": ""
|
||||||
|
},
|
||||||
"TenantDomains": {
|
"TenantDomains": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"PollSeconds": 60,
|
"PollSeconds": 60,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 认证与授权待补强清单
|
# 认证与授权待补强清单
|
||||||
|
|
||||||
|
> 2026-07-29 实施状态:可信代理启动校验、外部登录成员生命周期、Redis 跨实例频控与故障关闭、数据库 Capability、事务化 System Scope 审计、MassTransit EF Bus/Consumer Outbox、浏览器 Cookie/CSRF 主链路及 endpoint manifest 已落地。生产网关 ACL、RabbitMQ 4.x 重启/积压演练和按 job type 迁移旧轮询 Worker 仍属于部署验收项。
|
||||||
|
|
||||||
当前生效规则见 [认证、授权与 Host 安全策略](authentication-authorization-security.md)。本文只记录尚需补强的安全事项,不重复描述已实现体系。
|
当前生效规则见 [认证、授权与 Host 安全策略](authentication-authorization-security.md)。本文只记录尚需补强的安全事项,不重复描述已实现体系。
|
||||||
|
|
||||||
## P0:可信代理与 Host fail-closed
|
## P0:可信代理与 Host fail-closed
|
||||||
@@ -10,6 +12,8 @@
|
|||||||
- 受信代理只接受一跳转发,网关必须覆盖客户端伪造的 Forwarded Headers。
|
- 受信代理只接受一跳转发,网关必须覆盖客户端伪造的 Forwarded Headers。
|
||||||
- API 公网入口必须只能由受信网关访问。
|
- API 公网入口必须只能由受信网关访问。
|
||||||
|
|
||||||
|
实现说明:应用已强制 `ForwardLimit=1`,Production 缺少正式 Host、显式 `AllowedHosts` 或可信代理地址时启动失败;公网 ACL 和网关覆盖转发头由部署层落实。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- Host A + Tenant B token 返回 403。
|
- Host A + Tenant B token 返回 403。
|
||||||
@@ -23,6 +27,8 @@
|
|||||||
- 首次外部登录是否允许创建学生成员,必须由租户自注册策略控制。
|
- 首次外部登录是否允许创建学生成员,必须由租户自注册策略控制。
|
||||||
- 成员恢复只能由管理员显式操作并写审计。
|
- 成员恢复只能由管理员显式操作并写审计。
|
||||||
|
|
||||||
|
实现说明:`TenantAuthPolicy.AllowExternalStudentSelfRegistration` 控制首次外部登录;Disabled/Invited 不会被登录激活,管理员成员变更会同步撤销 Session、写审计并发布生命周期事件。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- Disabled 成员旧 access/refresh 立即失效。
|
- Disabled 成员旧 access/refresh 立即失效。
|
||||||
@@ -43,6 +49,8 @@ Tenant Active
|
|||||||
+ DataScope / Resource Scope
|
+ DataScope / Resource Scope
|
||||||
```
|
```
|
||||||
|
|
||||||
|
实现说明:`ProductModule`、`PlanModuleEntitlement`、`TenantModuleOverride` 与 `ICapabilityAccessEvaluator` 已进入数据库授权 Handler。模块目录采用渐进启用:只有进入固定目录的模块才强制套餐校验,避免迁移时误封未建档模块。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- 有 permission 但套餐不含模块,返回 403。
|
- 有 permission 但套餐不含模块,返回 403。
|
||||||
@@ -59,6 +67,8 @@ Tenant Active
|
|||||||
- All-only 资源必须显式声明。
|
- All-only 资源必须显式声明。
|
||||||
- 新增 Controller action 未进入 manifest 时测试失败。
|
- 新增 Controller action 未进入 manifest 时测试失败。
|
||||||
|
|
||||||
|
实现说明:`AuthorizationManifestTests` 对全部 Controller HTTP Action 的 method、route、匿名标记和 policy 生成稳定摘要;MVC convention 同时为全部非匿名 Controller endpoint 生成 realm、module、permission、operation、All-only 与 audit action 运行时元数据,变更会触发测试失败并要求安全评审。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- 列表、详情、创建、更新、删除、批量、导出和 Worker job 使用一致 DataScope。
|
- 列表、详情、创建、更新、删除、批量、导出和 Worker job 使用一致 DataScope。
|
||||||
@@ -70,6 +80,8 @@ Tenant Active
|
|||||||
- 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。
|
- 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。
|
||||||
- 跨租户写操作必须落 `AuditLog`。
|
- 跨租户写操作必须落 `AuditLog`。
|
||||||
|
|
||||||
|
实现说明:`SystemScopeRequest` 强制 caller、reason、target tenant 和 correlation ID;旧参数签名已移除。成功路径的 entered 审计、跨租户业务写入和 completed 审计处于同一 PostgreSQL 事务,异常路径回滚业务并持久化 entered/failed 审计。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- 未声明 reason 的 System Scope 创建失败。
|
- 未声明 reason 的 System Scope 创建失败。
|
||||||
@@ -82,3 +94,5 @@ Tenant Active
|
|||||||
- Access token 继续短期有效,不把角色和权限写入 JWT。
|
- Access token 继续短期有效,不把角色和权限写入 JWT。
|
||||||
- 登录审计和错误响应避免泄露手机号、openId、邮箱完整值。
|
- 登录审计和错误响应避免泄露手机号、openId、邮箱完整值。
|
||||||
- 出现第三方生态登录、开放 API 或多客户端授权需求时,再评估 OpenIddict / OIDC,不继续扩展私有协议。
|
- 出现第三方生态登录、开放 API 或多客户端授权需求时,再评估 OpenIddict / OIDC,不继续扩展私有协议。
|
||||||
|
|
||||||
|
实现说明:浏览器使用 `/api/browser-auth` + Secure/HttpOnly Cookie + Origin/CSRF 校验;原 `/api/auth` Bearer 契约继续供小程序、原生和服务调用。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。
|
- Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。
|
||||||
- JWT 只证明已认证会话,不承载可直接授权的角色或权限。
|
- JWT 只证明已认证会话,不承载可直接授权的角色或权限。
|
||||||
- 后台权限每次从数据库角色绑定解析;菜单只控制 UI 展示。
|
- 后台权限每次从数据库角色绑定解析;菜单只控制 UI 展示。
|
||||||
|
- 租户后台能力同时要求 Active tenant、有效订阅、模块权益和 operation permission;Capability 仍以 PostgreSQL 为准。
|
||||||
- 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用 All-only fail-closed。
|
- 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用 All-only fail-closed。
|
||||||
- 用户、成员、租户、后台角色、权限、SecurityStamp 或 Session 任一失效,旧 token 不能继续取得能力。
|
- 用户、成员、租户、后台角色、权限、SecurityStamp 或 Session 任一失效,旧 token 不能继续取得能力。
|
||||||
- Controller 默认要求认证;公开接口必须显式 `[AllowAnonymous]`。
|
- Controller 默认要求认证;公开接口必须显式 `[AllowAnonymous]`。
|
||||||
@@ -54,6 +55,8 @@ v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret}
|
|||||||
- 已轮换 token 被复用时视为重放,撤销整个 token family 并写审计。
|
- 已轮换 token 被复用时视为重放,撤销整个 token family 并写审计。
|
||||||
- logout 撤销当前 refresh token family;logout-all 更新 SecurityStamp 并撤销用户全部 Session。
|
- logout 撤销当前 refresh token family;logout-all 更新 SecurityStamp 并撤销用户全部 Session。
|
||||||
|
|
||||||
|
浏览器入口使用 `/api/browser-auth/*`:access/refresh token 仅写入 Secure、HttpOnly Cookie,响应体不返回 token;不安全方法必须通过同源 Origin 与双提交 CSRF 校验。`/api/auth/*` Bearer 契约继续供小程序、原生客户端和服务调用。
|
||||||
|
|
||||||
## Host 与 tenant 解析
|
## Host 与 tenant 解析
|
||||||
|
|
||||||
Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在 Authentication 前执行。
|
Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在 Authentication 前执行。
|
||||||
@@ -83,6 +86,8 @@ Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在
|
|||||||
- 平台角色不带租户键,不能自动读取租户业务数据。
|
- 平台角色不带租户键,不能自动读取租户业务数据。
|
||||||
- 菜单只决定 UI bootstrap 展示,不作为 API 授权依据。
|
- 菜单只决定 UI bootstrap 展示,不作为 API 授权依据。
|
||||||
- 后台 API 必须声明明确 permission;高风险写操作必须记录审计。
|
- 后台 API 必须声明明确 permission;高风险写操作必须记录审计。
|
||||||
|
- UI bootstrap 只返回“有效 permission 推导菜单”与有效 Capability 的交集;租户不能绑定当前无权使用的模块权限。
|
||||||
|
- Trial/Active 且在有效期内可写;PastDue/Cancelled/Expired 仅允许已有权益模块的历史读取。
|
||||||
|
|
||||||
DataScope:
|
DataScope:
|
||||||
|
|
||||||
@@ -97,6 +102,15 @@ DataScope:
|
|||||||
- `ISmsProvider` 只负责发送。
|
- `ISmsProvider` 只负责发送。
|
||||||
- 发送失败必须记录失败状态,不能留下可验证验证码。
|
- 发送失败必须记录失败状态,不能留下可验证验证码。
|
||||||
- 登录、绑定、找回密码等场景使用独立 purpose 和频控键。
|
- 登录、绑定、找回密码等场景使用独立 purpose 和频控键。
|
||||||
|
- Redis Lua 同时执行跨实例 IP、账号、租户、手机号和 purpose 窗口计数;key 只使用 GUID 或不可逆哈希。
|
||||||
|
- Redis 不可用时密码尝试、短信发送和短信校验失败关闭;普通授权请求仍直接查询 PostgreSQL。
|
||||||
|
|
||||||
|
## 可靠安全事件
|
||||||
|
|
||||||
|
- `Tiku.Contracts` 只包含版本化 DTO,不引用 EF、HTTP 或 Provider SDK。
|
||||||
|
- API 使用 MassTransit EF Bus Outbox,Worker consumer 使用 EF inbox/outbox;业务变更、审计和消息由同一 DbContext 提交。
|
||||||
|
- RabbitMQ 消息只负责非权威失效版本、菜单刷新和下游通知;成员、租户、Session 或套餐失效不等待 consumer。
|
||||||
|
- System Scope 只能通过完整 `SystemScopeRequest` 创建;成功路径将 entered 审计、跨租户业务写入和 completed 审计放入同一 PostgreSQL 事务。
|
||||||
|
|
||||||
## 审计与错误
|
## 审计与错误
|
||||||
|
|
||||||
@@ -121,6 +135,9 @@ DataScope:
|
|||||||
- JWT issuer、audience、当前 `KeyId`、RSA 私钥和旧公钥集合。
|
- JWT issuer、audience、当前 `KeyId`、RSA 私钥和旧公钥集合。
|
||||||
- Data Protection 证书。
|
- Data Protection 证书。
|
||||||
- CORS 明确 Origin。
|
- CORS 明确 Origin。
|
||||||
|
- Redis 7.2+ 连接串;Production 缺失时拒绝启动。
|
||||||
|
- RabbitMQ 4.x Host、virtual host 与凭据;Production 缺失时拒绝启动。
|
||||||
|
- 公网只暴露覆盖 Forwarded Headers 的可信网关,API ACL 只允许该网关访问。
|
||||||
- Secret encryption key。
|
- Secret encryption key。
|
||||||
- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。
|
- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。
|
||||||
|
|
||||||
|
|||||||
9
docs/architecture/endpoint-authorization-manifest.md
Normal file
9
docs/architecture/endpoint-authorization-manifest.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Endpoint authorization manifest
|
||||||
|
|
||||||
|
Controller 授权面由 `AuthorizationManifestTests` 按 HTTP method、route、controller/action、匿名标记和 policy 生成稳定摘要。
|
||||||
|
`EndpointAuthorizationMetadataConvention` 为全部非匿名 Controller endpoint 生成 realm、module、permission、CapabilityOperation、All-only DataScope 与 audit action 元数据,测试从运行时 `EndpointDataSource` 验证覆盖。新增、删除或修改 Action 时摘要测试必须失败,评审者确认元数据后才能更新 count/hash。
|
||||||
|
|
||||||
|
该清单是防止接口绕过评审的变更门禁;实际授权事实仍来自 PostgreSQL permission、Capability 和 DataScope,不能用摘要替代运行时校验。
|
||||||
|
|
||||||
|
- Action 数量:330
|
||||||
|
- SHA-256:`ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5`
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
# 本地开发快速开始
|
# 本地开发快速开始
|
||||||
|
|
||||||
|
## 可选分布式依赖
|
||||||
|
|
||||||
|
本地单实例开发可以不配置 Redis/RabbitMQ,认证频控仍保留 PostgreSQL/进程内防线;Production 两者均为启动必填项。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ConnectionStrings__Redis='localhost:6379,abortConnect=false'
|
||||||
|
export RabbitMq__Host='rabbitmq://localhost'
|
||||||
|
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;服务健康不等于认证授权验收完成。
|
||||||
|
|
||||||
这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。
|
这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。
|
||||||
|
|
||||||
## 1. 准备环境
|
## 1. 准备环境
|
||||||
|
|||||||
Reference in New Issue
Block a user