forked from xiongyuxing/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.Http" 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.SemanticKernel" Version="1.78.0" />
|
||||
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="Tiku.Api/Tiku.Api.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.Domain/Tiku.Domain.csproj" />
|
||||
<Project Path="Tiku.Infrastructure/Tiku.Infrastructure.csproj" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Api.Security;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -7,7 +8,8 @@ internal static class ApiPresentationExtensions
|
||||
{
|
||||
internal static IServiceCollection AddApiPresentation(this IServiceCollection services)
|
||||
{
|
||||
services.AddControllers()
|
||||
services.AddControllers(options =>
|
||||
options.Conventions.Add(new EndpointAuthorizationMetadataConvention()))
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
|
||||
@@ -29,6 +29,7 @@ public static class ApplicationBuilderExtensions
|
||||
app.UseRouting();
|
||||
app.UseCors(CorsOptions.PolicyName);
|
||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
app.UseMiddleware<BrowserCsrfMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<AuthRateLimitPartitionMiddleware>();
|
||||
app.UseRateLimiter();
|
||||
|
||||
@@ -29,6 +29,15 @@ internal static class AuthenticationExtensions
|
||||
var jwtOptions = configuration
|
||||
.GetSection("Security:Jwt")
|
||||
.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
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
@@ -84,11 +93,41 @@ internal static class AuthenticationExtensions
|
||||
};
|
||||
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,
|
||||
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)
|
||||
{
|
||||
var principal = context.Principal;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Serilog;
|
||||
using Tiku.Application;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -15,14 +17,51 @@ public static class DependencyInjection
|
||||
preserveStaticLogger: true);
|
||||
|
||||
builder.Services.AddApiPresentation();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddNetworkConfiguration(builder.Configuration);
|
||||
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApiRateLimiting(builder.Configuration);
|
||||
|
||||
var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString(
|
||||
builder.Configuration,
|
||||
builder.Environment.IsDevelopment());
|
||||
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.AddExternalServiceOptions(builder.Configuration, builder.Environment);
|
||||
|
||||
@@ -9,10 +9,15 @@ internal static class NetworkConfigurationExtensions
|
||||
{
|
||||
internal static IServiceCollection AddNetworkConfiguration(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment)
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed class AuthController(
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("sms/send")]
|
||||
[EndpointSummary("发送短信验证码")]
|
||||
[EndpointDescription("发送登录用途短信验证码,并应用租户级短信限流。")]
|
||||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
|
||||
@@ -86,6 +88,7 @@ public sealed class AuthController(
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("login/sms")]
|
||||
[EndpointSummary("短信验证码登录")]
|
||||
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
|
||||
@@ -195,6 +198,8 @@ public sealed class AuthController(
|
||||
|
||||
[HttpPost("logout-all")]
|
||||
[Authorize]
|
||||
[EndpointSummary("退出全部登录会话")]
|
||||
[EndpointDescription("撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -210,6 +215,8 @@ public sealed class AuthController(
|
||||
[AllowAnonymous]
|
||||
[HttpPost("password/change-required")]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[EndpointSummary("修改首次登录必改密码")]
|
||||
[EndpointDescription("校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。")]
|
||||
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
|
||||
[FromBody] RequiredPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class BackgroundJobsController(
|
||||
ITenantContext tenantContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户后台任务")]
|
||||
[ProducesResponseType<IReadOnlyCollection<BackgroundJobItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> List(
|
||||
[FromQuery] string? jobType,
|
||||
@@ -25,6 +26,7 @@ public sealed class BackgroundJobsController(
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[EndpointSummary("创建租户后台任务")]
|
||||
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Create(
|
||||
CreateBackgroundJobDto request,
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed class BackofficeController(
|
||||
{
|
||||
[HttpGet("tenant/ui-bootstrap")]
|
||||
[Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)]
|
||||
[EndpointSummary("查询租户后台菜单与权限")]
|
||||
[EndpointDescription("返回当前租户管理员可见的后台菜单、权限和模块启用状态。")]
|
||||
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeUiBootstrap>> GetTenantUiBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -24,6 +26,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpGet("tenant/bootstrap")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("查询租户角色管理初始化数据")]
|
||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeBootstrap>> GetTenantBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -32,6 +35,8 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpGet("platform/ui-bootstrap")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[EndpointSummary("查询平台后台菜单与权限")]
|
||||
[EndpointDescription("返回当前平台管理员可见的后台菜单、权限和模块启用状态。")]
|
||||
[ProducesResponseType<BackofficeUiBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeUiBootstrap>> GetPlatformUiBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -42,6 +47,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPost("tenant/roles")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("创建或更新租户后台角色")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertTenantRole(
|
||||
UpsertBackofficeRoleDto request,
|
||||
@@ -52,6 +58,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("tenant/roles/{roleId:guid}/bindings")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("替换租户后台角色权限绑定")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> ReplaceTenantRoleBindings(
|
||||
Guid roleId,
|
||||
@@ -63,6 +70,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("tenant/users/{userId:guid}/roles")]
|
||||
[Authorize(Policy = BackendPermissions.TenantRoleManage)]
|
||||
[EndpointSummary("替换租户用户后台角色")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ReplaceTenantUserRoles(
|
||||
Guid userId,
|
||||
@@ -75,6 +83,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpGet("platform/bootstrap")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("查询平台角色管理初始化数据")]
|
||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeBootstrap>> GetPlatformBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -83,6 +92,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPost("platform/roles")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("创建或更新平台后台角色")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> UpsertPlatformRole(
|
||||
UpsertBackofficeRoleDto request,
|
||||
@@ -93,6 +103,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("platform/roles/{roleId:guid}/bindings")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("替换平台后台角色权限绑定")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> ReplacePlatformRoleBindings(
|
||||
Guid roleId,
|
||||
@@ -104,6 +115,7 @@ public sealed class BackofficeController(
|
||||
|
||||
[HttpPut("platform/users/{userId:guid}/roles")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("替换平台用户后台角色")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ReplacePlatformUserRoles(
|
||||
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
|
||||
{
|
||||
[HttpGet("settings")]
|
||||
[EndpointSummary("查询佣金配置")]
|
||||
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken));
|
||||
|
||||
[HttpPut("settings")]
|
||||
[EndpointSummary("保存佣金配置")]
|
||||
public async Task<ActionResult<CommissionSettingsItem>> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpPut("member-rate")]
|
||||
[EndpointSummary("调整成员佣金比例")]
|
||||
public async Task<ActionResult<object>> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpGet("summary")]
|
||||
[EndpointSummary("查询佣金统计摘要")]
|
||||
public async Task<ActionResult<CommissionSummaryItem>> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
|
||||
[HttpGet("orders")]
|
||||
[EndpointSummary("查询佣金来源订单")]
|
||||
public async Task<ActionResult<CommissionList<CommissionSourceItem>>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
|
||||
[HttpGet("settlements")]
|
||||
[EndpointSummary("查询佣金结算单")]
|
||||
public async Task<ActionResult<CommissionList<CommissionSettlementItemDto>>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
|
||||
[HttpGet("settlements/export")]
|
||||
[EndpointSummary("导出佣金结算单")]
|
||||
public async Task<ActionResult<CommissionExportItem>> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken));
|
||||
|
||||
[HttpPost("settlements/generate")]
|
||||
[EndpointSummary("生成佣金结算单")]
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpPost("settlements/status")]
|
||||
[EndpointSummary("更新佣金结算单状态")]
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpGet("settlements/proofs")]
|
||||
[EndpointSummary("查询佣金结算凭证")]
|
||||
public async Task<ActionResult<CommissionList<CommissionProofItem>>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken));
|
||||
|
||||
[HttpPost("settlements/proofs")]
|
||||
[EndpointSummary("创建佣金结算凭证")]
|
||||
public async Task<ActionResult<CommissionProofItem>> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
[HttpPost("settlements/proofs/status")]
|
||||
[EndpointSummary("更新佣金结算凭证状态")]
|
||||
public async Task<ActionResult<CommissionProofItem>> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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;
|
||||
|
||||
@@ -8,7 +14,11 @@ namespace Tiku.Api.Controllers;
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/health")]
|
||||
public sealed class HealthController : ControllerBase
|
||||
public sealed class HealthController(
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
MessagingOptions messagingOptions,
|
||||
HealthCheckService healthCheckService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("健康检查")]
|
||||
@@ -21,4 +31,30 @@ public sealed class HealthController : ControllerBase
|
||||
"tiku-api",
|
||||
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
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("获取当前登录用户")]
|
||||
[EndpointDescription("根据 Bearer Token 返回当前用户基础信息和活跃租户成员摘要。")]
|
||||
public async Task<ActionResult<MeResponse>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null)
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
{
|
||||
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
|
||||
[HttpGet("authenticated")]
|
||||
[EndpointSummary("诊断已登录用户上下文")]
|
||||
public ActionResult<object> Authenticated()
|
||||
{
|
||||
return Ok(new
|
||||
@@ -24,6 +25,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[HttpGet("tenant-member")]
|
||||
[EndpointSummary("诊断当前租户成员上下文")]
|
||||
public ActionResult<object> TenantMember()
|
||||
{
|
||||
return Ok(new
|
||||
@@ -34,6 +36,7 @@ public sealed class SecurityDiagnosticsController(
|
||||
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[HttpGet("tenant-admin")]
|
||||
[EndpointSummary("诊断当前租户管理员上下文")]
|
||||
public ActionResult<object> TenantAdmin()
|
||||
{
|
||||
return Ok(new
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class TaxonomyController(
|
||||
ITaxonomyService taxonomyService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户分类节点")]
|
||||
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
|
||||
{
|
||||
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
|
||||
@@ -22,6 +23,7 @@ public sealed class TaxonomyController(
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
[EndpointSummary("创建租户分类节点")]
|
||||
public Task<TaxonomyNodeItem> Create(
|
||||
CreateTaxonomyNodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -15,12 +15,14 @@ public sealed class TenantFrontendConfigController(
|
||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户前端配置")]
|
||||
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("draft")]
|
||||
[EndpointSummary("保存租户前端配置草稿")]
|
||||
public Task<TenantFrontendConfigItem> SaveDraft(
|
||||
SaveTenantFrontendConfigDraftDto request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -32,6 +34,7 @@ public sealed class TenantFrontendConfigController(
|
||||
}
|
||||
|
||||
[HttpPost("publish")]
|
||||
[EndpointSummary("发布租户前端配置")]
|
||||
public Task<TenantFrontendConfigItem> Publish(
|
||||
PublishTenantFrontendConfigDto request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class TenantsController(
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("current")]
|
||||
[EndpointSummary("查询当前租户")]
|
||||
[EndpointDescription("返回当前请求租户及当前用户在该租户内的成员角色。")]
|
||||
public async Task<ActionResult<CurrentTenantResponse>> GetCurrent(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null || currentTenant.TenantId is null)
|
||||
|
||||
@@ -3,11 +3,24 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
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)
|
||||
{
|
||||
var policy = context.GetEndpoint()?
|
||||
@@ -24,11 +37,70 @@ public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next)
|
||||
if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null)
|
||||
{
|
||||
await CaptureAccountHashAsync(context, propertyName);
|
||||
if (redisSecurityStore.IsConfigured)
|
||||
{
|
||||
await ConsumeDistributedLimitAsync(context, policy!);
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (exception is BrowserOriginException)
|
||||
{
|
||||
await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, "browser_origin_rejected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is TenantContextConflictException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -380,6 +386,7 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
"tenant_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
|
||||
"auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
||||
"auth_security_unavailable" => StatusCodes.Status503ServiceUnavailable,
|
||||
"session_revoked" => StatusCodes.Status401Unauthorized,
|
||||
_ => StatusCodes.Status401Unauthorized
|
||||
};
|
||||
@@ -494,7 +501,7 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
{
|
||||
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,
|
||||
_ => 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 System.Net;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Options;
|
||||
@@ -41,6 +42,32 @@ public static class OptionsValidation
|
||||
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)
|
||||
{
|
||||
return Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Tiku.Application.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;
|
||||
}
|
||||
|
||||
internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
|
||||
internal sealed class TenantPermissionAuthorizationHandler(
|
||||
ICurrentAccessContext accessContext,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator,
|
||||
IHttpContextAccessor httpContextAccessor) :
|
||||
AuthorizationHandler<TenantPermissionRequirement>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(
|
||||
@@ -86,11 +90,27 @@ internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) :
|
||||
@@ -176,6 +196,8 @@ public static class AccessAuthorizationServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
services.TryAddScoped<ICapabilityAccessEvaluator, CompatibilityCapabilityAccessEvaluator>();
|
||||
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
|
||||
@@ -252,3 +274,18 @@ public static class AccessAuthorizationServiceCollectionExtensions
|
||||
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
|
||||
}
|
||||
},
|
||||
"Redis": {
|
||||
"KeyPrefix": "tiku"
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"BrowserAuth": {
|
||||
"AllowedOrigins": []
|
||||
},
|
||||
"Storage": {
|
||||
"DefaultProvider": "aliyun_oss",
|
||||
"DefaultBucket": "tenant-assets",
|
||||
|
||||
@@ -20,5 +20,8 @@ public sealed class SmsRateLimitedException()
|
||||
public sealed class AuthProviderNotConfiguredException(string provider)
|
||||
: 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")
|
||||
: 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;
|
||||
|
||||
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
|
||||
{
|
||||
Task ExecuteAsync(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
SystemScopeRequest request,
|
||||
Func<IServiceProvider, CancellationToken, Task> operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TResult> ExecuteAsync<TResult>(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
SystemScopeRequest request,
|
||||
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
||||
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 DiscountType { Percent, Fixed }
|
||||
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 RefundEntitlementAction { None, RevokeOnSuccess }
|
||||
public enum ReconciliationBillType { Payment, Refund, Combined }
|
||||
|
||||
@@ -18,6 +18,30 @@ public sealed class PlatformSaasPlan : AuditableEntity
|
||||
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 Guid TenantId { get; set; }
|
||||
@@ -177,6 +201,8 @@ public sealed class PlatformDunningNotificationEvent : AuditableTenantEntity
|
||||
|
||||
public enum PlatformBillingCycle { Monthly, Quarterly, Yearly, OneTime }
|
||||
public enum PlatformSaasPlanStatus { Active, Archived }
|
||||
public enum ProductModuleStatus { Active, Archived }
|
||||
public enum TenantModuleOverrideMode { Enabled, Disabled }
|
||||
public enum TenantInvoiceTitleType { None, NormalVat, SpecialVat }
|
||||
public enum TenantInvoiceType { Subscription, ServiceFee, UsageOverage, ManualAdjustment }
|
||||
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 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 int SchemaVersion { get; set; } = 1;
|
||||
|
||||
@@ -404,6 +404,10 @@ public sealed class AuthService(
|
||||
request.TenantId.Value,
|
||||
user.Id,
|
||||
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(
|
||||
request.Realm,
|
||||
@@ -502,7 +506,6 @@ public sealed class AuthService(
|
||||
existingIdentity.UserId = user.Id;
|
||||
existingIdentity.OpenId = wechatIdentity.OpenId;
|
||||
existingIdentity.UnionId = wechatIdentity.UnionId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -552,22 +555,26 @@ public sealed class AuthService(
|
||||
membership.UserId == userId &&
|
||||
membership.Role == TenantRole.Student,
|
||||
cancellationToken);
|
||||
if (studentMembership is null)
|
||||
if (studentMembership is not null)
|
||||
{
|
||||
dbContext.TenantMemberships.Add(new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
studentMembership.Status = MembershipStatus.Active;
|
||||
// Invited and Disabled memberships require an explicit administrator action.
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -7,14 +8,25 @@ using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed class SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
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 SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||
@@ -141,6 +153,7 @@ public sealed class SmsVerificationService(
|
||||
|
||||
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await ConsumeVerificationLimitAsync(tenantId, normalizedPhone, purpose, cancellationToken);
|
||||
var codeHash = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
normalizedPhone,
|
||||
@@ -184,6 +197,39 @@ public sealed class SmsVerificationService(
|
||||
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(
|
||||
SendSmsCodeRequest request,
|
||||
string phone,
|
||||
@@ -193,6 +239,27 @@ public sealed class SmsVerificationService(
|
||||
var limits = BuildRateLimits(request, phone);
|
||||
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())
|
||||
{
|
||||
await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace Tiku.Infrastructure.Backoffice;
|
||||
|
||||
internal sealed class BackofficeService(
|
||||
TikuDbContext dbContext,
|
||||
IOperationAuditService auditService) : IBackofficeService
|
||||
IOperationAuditService auditService,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackofficeService
|
||||
{
|
||||
private static readonly BuiltinPermission[] BuiltinPermissions =
|
||||
[
|
||||
@@ -63,7 +64,11 @@ internal sealed class BackofficeService(
|
||||
}
|
||||
|
||||
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(
|
||||
BackendPermissionArea.Tenant,
|
||||
permissionCodes,
|
||||
@@ -100,10 +105,18 @@ internal sealed class BackofficeService(
|
||||
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
|
||||
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.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()
|
||||
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
|
||||
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
menus = menus.Where(item => item.PermissionCode is null ||
|
||||
enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)).ToArray();
|
||||
return new BackofficeBootstrap(
|
||||
permissions.Select(ToPermissionItem).ToArray(),
|
||||
menus.Select(ToMenuItem).ToArray(),
|
||||
@@ -350,6 +363,19 @@ internal sealed class BackofficeService(
|
||||
var normalizedPermissions = NormalizeCodes(permissionCodes);
|
||||
var normalizedMenus = NormalizeCodes(menuCodes);
|
||||
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 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);
|
||||
@@ -401,6 +427,33 @@ internal sealed class BackofficeService(
|
||||
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)
|
||||
{
|
||||
var count = await dbContext.BackendMenus.CountAsync(
|
||||
|
||||
@@ -30,8 +30,9 @@ public sealed class TaxonomyService(
|
||||
}
|
||||
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"List platform taxonomy with tenant extensions",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"List platform taxonomy with tenant extensions", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
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.")
|
||||
};
|
||||
parent = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Validate taxonomy extension parent ownership",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -137,8 +139,9 @@ public sealed class TaxonomyService(
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Resolve platform taxonomy owner",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"Resolve platform taxonomy owner", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) => await provider.GetRequiredService<TikuDbContext>()
|
||||
.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||
|
||||
@@ -40,7 +40,9 @@ public sealed class DirectContentService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -57,7 +59,10 @@ public sealed class DirectContentService(
|
||||
await SyncPrimaryCollectionItemAsync(actor, question, 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ using Tiku.Infrastructure.StudyContent;
|
||||
using Tiku.Infrastructure.TenantAdmin;
|
||||
using Tiku.Infrastructure.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using StackExchange.Redis;
|
||||
using MassTransit;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -83,6 +86,8 @@ public static class DependencyInjection
|
||||
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
|
||||
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
||||
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||
services.AddSingleton<IRedisSecurityStore, NullRedisSecurityStore>();
|
||||
services.AddScoped<ISecurityEventPublisher, NullSecurityEventPublisher>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
|
||||
@@ -121,6 +126,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
@@ -150,4 +156,78 @@ public static class DependencyInjection
|
||||
|
||||
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(
|
||||
TikuDbContext dbContext,
|
||||
IServiceProvider serviceProvider,
|
||||
ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackgroundJobService
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -24,10 +25,19 @@ internal sealed class BackgroundJobService(
|
||||
CreateBackgroundJobCommand command,
|
||||
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
|
||||
{
|
||||
TenantId = command.TenantId,
|
||||
JobType = NormalizeJobType(command.JobType),
|
||||
JobType = normalizedJobType,
|
||||
Payload = command.Payload,
|
||||
RunAfter = command.RunAfter,
|
||||
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
||||
@@ -55,6 +65,19 @@ internal sealed class BackgroundJobService(
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
job.TenantId,
|
||||
ResolveCapabilityModule(job.JobType),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
{
|
||||
job.Status = BackgroundJobStatus.Failed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = "Tenant capability was revoked before job execution.";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
processed++;
|
||||
continue;
|
||||
}
|
||||
job.Status = BackgroundJobStatus.Processing;
|
||||
job.LockedBy = workerId;
|
||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||
@@ -64,8 +87,9 @@ internal sealed class BackgroundJobService(
|
||||
try
|
||||
{
|
||||
var result = await tenantExecutionScope.ExecuteAsync(
|
||||
job.TenantId,
|
||||
$"Background job {job.JobType}",
|
||||
new SystemScopeRequest(
|
||||
job.TenantId, SystemScopeCallerType.Worker, workerId,
|
||||
$"Background job {job.JobType}", job.Id.ToString("N")),
|
||||
(provider, token) => ProcessCoreAsync(provider, job, token),
|
||||
cancellationToken);
|
||||
job.Status = BackgroundJobStatus.Succeeded;
|
||||
@@ -328,6 +352,15 @@ internal sealed class BackgroundJobService(
|
||||
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)
|
||||
{
|
||||
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
@@ -1024,8 +1024,9 @@ public sealed class LearningActivityService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Lock published question versions for a new practice session",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
||||
"Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -1076,8 +1077,9 @@ public sealed class LearningActivityService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read locked question versions for a tenant practice session",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
||||
"Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
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>
|
||||
{
|
||||
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>
|
||||
{
|
||||
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.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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -11755,6 +11973,43 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -12304,6 +12559,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_platform_saas_plans");
|
||||
|
||||
b.HasAlternateKey("Code")
|
||||
.HasName("ak_platform_saas_plans_code");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.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 =>
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -13642,6 +14021,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
@@ -14618,6 +15027,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
@@ -17550,6 +17973,25 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.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 =>
|
||||
{
|
||||
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_~");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
@@ -17895,6 +18355,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
|
||||
@@ -16,6 +16,8 @@ using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using MassTransit;
|
||||
using MassTransit.EntityFrameworkCoreIntegration;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -47,6 +49,7 @@ public sealed class TikuDbContext(
|
||||
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
||||
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
|
||||
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
|
||||
public DbSet<TenantAuthPolicy> TenantAuthPolicies => Set<TenantAuthPolicy>();
|
||||
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
|
||||
public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
|
||||
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
|
||||
@@ -178,6 +181,9 @@ public sealed class TikuDbContext(
|
||||
public DbSet<TenantThemeTemplate> TenantThemeTemplates => Set<TenantThemeTemplate>();
|
||||
public DbSet<TenantThemeConfig> TenantThemeConfigs => Set<TenantThemeConfig>();
|
||||
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<TenantInvoice> TenantInvoices => Set<TenantInvoice>();
|
||||
public DbSet<TenantInvoiceItem> TenantInvoiceItems => Set<TenantInvoiceItem>();
|
||||
@@ -201,6 +207,12 @@ public sealed class TikuDbContext(
|
||||
modelBuilder.HasPostgresExtension("ltree");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||
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);
|
||||
ValidateTenantModel(modelBuilder);
|
||||
modelBuilder.UseSnakeCaseIdentifiers();
|
||||
|
||||
@@ -10,6 +10,7 @@ using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
|
||||
@@ -152,6 +153,11 @@ internal sealed class PlatformAdminService(
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
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 });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToTenantItem(tenant, 0, null);
|
||||
@@ -164,7 +170,7 @@ internal sealed class PlatformAdminService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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
|
||||
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
@@ -181,6 +187,13 @@ internal sealed class PlatformAdminService(
|
||||
ToBillingStatus = tenant.BillingStatus,
|
||||
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);
|
||||
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||
var expiresAt = await dbContext.TenantSubscriptions
|
||||
@@ -255,7 +268,7 @@ internal sealed class PlatformAdminService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
|
||||
@@ -285,6 +298,27 @@ internal sealed class PlatformAdminService(
|
||||
subscription.Status,
|
||||
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);
|
||||
return ToSubscriptionItem(subscription);
|
||||
}, cancellationToken);
|
||||
@@ -719,11 +753,23 @@ internal sealed class PlatformAdminService(
|
||||
string reason,
|
||||
Func<TikuDbContext, Task<TResult>> operation,
|
||||
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(
|
||||
null,
|
||||
reason,
|
||||
async (provider, _) => await operation(provider.GetRequiredService<TikuDbContext>()),
|
||||
new SystemScopeRequest(
|
||||
null,
|
||||
SystemScopeCallerType.Platform,
|
||||
nameof(PlatformAdminService),
|
||||
reason,
|
||||
Guid.NewGuid().ToString("N")),
|
||||
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,9 @@ public sealed class QuestionBankQueryService(
|
||||
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
||||
? []
|
||||
: await tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform question banks for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||
"List platform question banks for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -336,8 +337,9 @@ public sealed class QuestionBankQueryService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform questions for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||
"List platform questions for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -380,8 +382,9 @@ public sealed class QuestionBankQueryService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read platform question versions for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||
"Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
@@ -73,8 +73,9 @@ public sealed class QuestionReferenceService(
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
var ownerTenantId = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Resolve a platform question for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionReferenceService),
|
||||
"Resolve a platform question for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
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.Logging;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
@@ -9,42 +14,102 @@ public sealed class TenantExecutionScope(
|
||||
ILogger<TenantExecutionScope> logger) : ITenantExecutionScope
|
||||
{
|
||||
public Task ExecuteAsync(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
SystemScopeRequest request,
|
||||
Func<IServiceProvider, CancellationToken, Task> operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ExecuteAsync<object?>(
|
||||
targetTenantId,
|
||||
reason,
|
||||
async (provider, token) =>
|
||||
{
|
||||
await operation(provider, token);
|
||||
return null;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync<object?>(request, async (provider, token) =>
|
||||
{
|
||||
await operation(provider, token);
|
||||
return null;
|
||||
}, cancellationToken);
|
||||
|
||||
public async Task<TResult> ExecuteAsync<TResult>(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
SystemScopeRequest request,
|
||||
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
|
||||
}
|
||||
|
||||
Validate(request, operation);
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var initializer = scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>();
|
||||
initializer.InitializeSystem(targetTenantId, reason);
|
||||
initializer.InitializeSystem(request.TargetTenantId, request.Reason);
|
||||
logger.LogWarning(
|
||||
"Entering audited system tenant scope. TargetTenantId={TargetTenantId} Reason={Reason}",
|
||||
targetTenantId,
|
||||
reason);
|
||||
"Entering audited system scope. CallerType={CallerType} Caller={Caller} TargetTenantId={TargetTenantId} CorrelationId={CorrelationId} Reason={Reason}",
|
||||
request.CallerType,
|
||||
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.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||||
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
||||
@@ -28,7 +29,8 @@ public sealed class TenantAdminDirectService(
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
INotificationProvider notificationProvider,
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IAuthSessionStore sessionStore) : ITenantAdminDirectService
|
||||
IAuthSessionStore sessionStore,
|
||||
ISecurityEventPublisher securityEventPublisher) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
TenantAdminActor actor,
|
||||
@@ -1154,6 +1156,7 @@ public sealed class TenantAdminDirectService(
|
||||
}
|
||||
|
||||
var isNew = membership is null;
|
||||
var previousStatus = membership?.Status.ToString() ?? "none";
|
||||
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
|
||||
{
|
||||
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 securityEventPublisher.MembershipChangedAsync(
|
||||
actor.TenantId, user.Id, previousStatus, status.ToString(),
|
||||
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
}
|
||||
@@ -1206,9 +1212,13 @@ public sealed class TenantAdminDirectService(
|
||||
}
|
||||
|
||||
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
|
||||
var previousStatus = membership.Status;
|
||||
membership.Status = MembershipStatus.Disabled;
|
||||
await RevokeSessionsAsync(actor.TenantId, membership.UserId, 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);
|
||||
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Contracts\Tiku.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -16,6 +17,11 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<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="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
|
||||
@@ -34,8 +34,17 @@ public sealed class ApiTestFactory(
|
||||
{
|
||||
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||
|
||||
public string DatabaseConnectionString => database.ConnectionString;
|
||||
|
||||
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) =>
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
@@ -61,7 +70,7 @@ public sealed class ApiTestFactory(
|
||||
descriptor.ServiceType == typeof(NpgsqlDataSource) ||
|
||||
descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) ||
|
||||
descriptor.ServiceType == typeof(DbContextOptions<TikuDbContext>) ||
|
||||
descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true)
|
||||
descriptor.ServiceType == typeof(TikuDbContext))
|
||||
.ToArray())
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
|
||||
@@ -305,6 +305,123 @@ public sealed class AuthEndpointTests
|
||||
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(
|
||||
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])
|
||||
}));
|
||||
}
|
||||
|
||||
[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.Infrastructure;
|
||||
using Tiku.Worker;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
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.");
|
||||
builder.Services.AddApplication();
|
||||
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>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"RabbitMq": {
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
"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)。本文只记录尚需补强的安全事项,不重复描述已实现体系。
|
||||
|
||||
## P0:可信代理与 Host fail-closed
|
||||
@@ -10,6 +12,8 @@
|
||||
- 受信代理只接受一跳转发,网关必须覆盖客户端伪造的 Forwarded Headers。
|
||||
- API 公网入口必须只能由受信网关访问。
|
||||
|
||||
实现说明:应用已强制 `ForwardLimit=1`,Production 缺少正式 Host、显式 `AllowedHosts` 或可信代理地址时启动失败;公网 ACL 和网关覆盖转发头由部署层落实。
|
||||
|
||||
验收:
|
||||
|
||||
- Host A + Tenant B token 返回 403。
|
||||
@@ -23,6 +27,8 @@
|
||||
- 首次外部登录是否允许创建学生成员,必须由租户自注册策略控制。
|
||||
- 成员恢复只能由管理员显式操作并写审计。
|
||||
|
||||
实现说明:`TenantAuthPolicy.AllowExternalStudentSelfRegistration` 控制首次外部登录;Disabled/Invited 不会被登录激活,管理员成员变更会同步撤销 Session、写审计并发布生命周期事件。
|
||||
|
||||
验收:
|
||||
|
||||
- Disabled 成员旧 access/refresh 立即失效。
|
||||
@@ -43,6 +49,8 @@ Tenant Active
|
||||
+ DataScope / Resource Scope
|
||||
```
|
||||
|
||||
实现说明:`ProductModule`、`PlanModuleEntitlement`、`TenantModuleOverride` 与 `ICapabilityAccessEvaluator` 已进入数据库授权 Handler。模块目录采用渐进启用:只有进入固定目录的模块才强制套餐校验,避免迁移时误封未建档模块。
|
||||
|
||||
验收:
|
||||
|
||||
- 有 permission 但套餐不含模块,返回 403。
|
||||
@@ -59,6 +67,8 @@ Tenant Active
|
||||
- All-only 资源必须显式声明。
|
||||
- 新增 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。
|
||||
@@ -70,6 +80,8 @@ Tenant Active
|
||||
- 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。
|
||||
- 跨租户写操作必须落 `AuditLog`。
|
||||
|
||||
实现说明:`SystemScopeRequest` 强制 caller、reason、target tenant 和 correlation ID;旧参数签名已移除。成功路径的 entered 审计、跨租户业务写入和 completed 审计处于同一 PostgreSQL 事务,异常路径回滚业务并持久化 entered/failed 审计。
|
||||
|
||||
验收:
|
||||
|
||||
- 未声明 reason 的 System Scope 创建失败。
|
||||
@@ -82,3 +94,5 @@ Tenant Active
|
||||
- Access token 继续短期有效,不把角色和权限写入 JWT。
|
||||
- 登录审计和错误响应避免泄露手机号、openId、邮箱完整值。
|
||||
- 出现第三方生态登录、开放 API 或多客户端授权需求时,再评估 OpenIddict / OIDC,不继续扩展私有协议。
|
||||
|
||||
实现说明:浏览器使用 `/api/browser-auth` + Secure/HttpOnly Cookie + Origin/CSRF 校验;原 `/api/auth` Bearer 契约继续供小程序、原生和服务调用。
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。
|
||||
- JWT 只证明已认证会话,不承载可直接授权的角色或权限。
|
||||
- 后台权限每次从数据库角色绑定解析;菜单只控制 UI 展示。
|
||||
- 租户后台能力同时要求 Active tenant、有效订阅、模块权益和 operation permission;Capability 仍以 PostgreSQL 为准。
|
||||
- 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用 All-only fail-closed。
|
||||
- 用户、成员、租户、后台角色、权限、SecurityStamp 或 Session 任一失效,旧 token 不能继续取得能力。
|
||||
- Controller 默认要求认证;公开接口必须显式 `[AllowAnonymous]`。
|
||||
@@ -54,6 +55,8 @@ v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret}
|
||||
- 已轮换 token 被复用时视为重放,撤销整个 token family 并写审计。
|
||||
- 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 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在 Authentication 前执行。
|
||||
@@ -83,6 +86,8 @@ Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在
|
||||
- 平台角色不带租户键,不能自动读取租户业务数据。
|
||||
- 菜单只决定 UI bootstrap 展示,不作为 API 授权依据。
|
||||
- 后台 API 必须声明明确 permission;高风险写操作必须记录审计。
|
||||
- UI bootstrap 只返回“有效 permission 推导菜单”与有效 Capability 的交集;租户不能绑定当前无权使用的模块权限。
|
||||
- Trial/Active 且在有效期内可写;PastDue/Cancelled/Expired 仅允许已有权益模块的历史读取。
|
||||
|
||||
DataScope:
|
||||
|
||||
@@ -97,6 +102,15 @@ DataScope:
|
||||
- `ISmsProvider` 只负责发送。
|
||||
- 发送失败必须记录失败状态,不能留下可验证验证码。
|
||||
- 登录、绑定、找回密码等场景使用独立 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 私钥和旧公钥集合。
|
||||
- Data Protection 证书。
|
||||
- CORS 明确 Origin。
|
||||
- Redis 7.2+ 连接串;Production 缺失时拒绝启动。
|
||||
- RabbitMQ 4.x Host、virtual host 与凭据;Production 缺失时拒绝启动。
|
||||
- 公网只暴露覆盖 Forwarded Headers 的可信网关,API ACL 只允许该网关访问。
|
||||
- Secret encryption key。
|
||||
- 短信、对象存储、支付、通知和 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,并完成平台管理员的首次登录。
|
||||
|
||||
## 1. 准备环境
|
||||
|
||||
Reference in New Issue
Block a user