feat(security): add distributed authorization foundation

This commit is contained in:
2026-07-29 10:40:10 +08:00
parent c7f9a4e3c9
commit df88fa19cb
76 changed files with 22020 additions and 88 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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; } = [];
}

View File

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

View File

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

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

View File

@@ -65,6 +65,18 @@
"DeviceRequestsPerHour": 10
}
},
"Redis": {
"KeyPrefix": "tiku"
},
"RabbitMq": {
"Host": "",
"VirtualHost": "/",
"Username": "",
"Password": ""
},
"BrowserAuth": {
"AllowedOrigins": []
},
"Storage": {
"DefaultProvider": "aliyun_oss",
"DefaultBucket": "tenant-assets",

View File

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

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

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

View File

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

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

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,6 @@
namespace Tiku.Infrastructure.Security;
public sealed class RedisSecurityConnectionOptions
{
public string ConnectionString { get; set; } = string.Empty;
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

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

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

View File

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

View File

@@ -1,4 +1,10 @@
{
"RabbitMq": {
"Host": "",
"VirtualHost": "/",
"Username": "",
"Password": ""
},
"TenantDomains": {
"Enabled": true,
"PollSeconds": 60,

View File

@@ -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 契约继续供小程序、原生和服务调用。

View File

@@ -12,6 +12,7 @@
- Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。
- JWT 只证明已认证会话,不承载可直接授权的角色或权限。
- 后台权限每次从数据库角色绑定解析;菜单只控制 UI 展示。
- 租户后台能力同时要求 Active tenant、有效订阅、模块权益和 operation permissionCapability 仍以 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 familylogout-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 OutboxWorker 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 配置读取密钥。

View 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`

View File

@@ -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. 准备环境