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

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