feat(security): add distributed authorization foundation
This commit is contained in:
@@ -404,6 +404,10 @@ public sealed class AuthService(
|
||||
request.TenantId.Value,
|
||||
user.Id,
|
||||
cancellationToken);
|
||||
// Persist the external identity and membership together only after the
|
||||
// tenant policy and existing membership state have accepted the login.
|
||||
// A denied first login must not leave a user or provider identity behind.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
@@ -502,7 +506,6 @@ public sealed class AuthService(
|
||||
existingIdentity.UserId = user.Id;
|
||||
existingIdentity.OpenId = wechatIdentity.OpenId;
|
||||
existingIdentity.UnionId = wechatIdentity.UnionId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -552,22 +555,26 @@ public sealed class AuthService(
|
||||
membership.UserId == userId &&
|
||||
membership.Role == TenantRole.Student,
|
||||
cancellationToken);
|
||||
if (studentMembership is null)
|
||||
if (studentMembership is not null)
|
||||
{
|
||||
dbContext.TenantMemberships.Add(new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
studentMembership.Status = MembershipStatus.Active;
|
||||
// Invited and Disabled memberships require an explicit administrator action.
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var policy = await dbContext.TenantAuthPolicies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (policy is not null && !policy.AllowExternalStudentSelfRegistration)
|
||||
{
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
dbContext.TenantMemberships.Add(new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -7,14 +8,25 @@ using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed class SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
||||
{
|
||||
public SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
IOptions<SmsSecurityOptions> securityOptions)
|
||||
: this(dbContext, smsProvider, new NullRedisSecurityStore(), securityOptions)
|
||||
{
|
||||
}
|
||||
|
||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
|
||||
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
|
||||
private readonly SmsSecurityOptions options = securityOptions.Value;
|
||||
@@ -141,6 +153,7 @@ public sealed class SmsVerificationService(
|
||||
|
||||
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await ConsumeVerificationLimitAsync(tenantId, normalizedPhone, purpose, cancellationToken);
|
||||
var codeHash = SmsCodeHashing.Hash(
|
||||
tenantId,
|
||||
normalizedPhone,
|
||||
@@ -184,6 +197,39 @@ public sealed class SmsVerificationService(
|
||||
throw new InvalidCredentialsException("invalid_sms_code");
|
||||
}
|
||||
|
||||
private async Task ConsumeVerificationLimitAsync(
|
||||
Guid tenantId,
|
||||
string phone,
|
||||
SmsPurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!redisSecurityStore.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone)))
|
||||
.ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
var result = await redisSecurityStore.ConsumeAsync(
|
||||
[
|
||||
new DistributedRateLimitBucket(
|
||||
$"sms-verify:{tenantId:N}:{purpose.ToString().ToLowerInvariant()}:{phoneHash}",
|
||||
options.MaxVerificationAttempts,
|
||||
CodeLifetime)
|
||||
], cancellationToken);
|
||||
if (!result.Allowed)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
}
|
||||
catch (RedisSecurityUnavailableException)
|
||||
{
|
||||
throw new AuthSecurityUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConsumeRateLimitsAsync(
|
||||
SendSmsCodeRequest request,
|
||||
string phone,
|
||||
@@ -193,6 +239,27 @@ public sealed class SmsVerificationService(
|
||||
var limits = BuildRateLimits(request, phone);
|
||||
var bucketStart = TruncateToHour(now);
|
||||
|
||||
if (redisSecurityStore.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var distributed = await redisSecurityStore.ConsumeAsync(
|
||||
limits.Select(limit => new DistributedRateLimitBucket(
|
||||
$"sms-send:{request.TenantId:N}:{ToSnakeCase(limit.Dimension)}:{limit.ScopeHash}",
|
||||
limit.Maximum,
|
||||
TimeSpan.FromHours(1))).ToArray(),
|
||||
cancellationToken);
|
||||
if (!distributed.Allowed)
|
||||
{
|
||||
throw new SmsRateLimitedException();
|
||||
}
|
||||
}
|
||||
catch (RedisSecurityUnavailableException)
|
||||
{
|
||||
throw new AuthSecurityUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbContext.Database.IsRelational())
|
||||
{
|
||||
await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace Tiku.Infrastructure.Backoffice;
|
||||
|
||||
internal sealed class BackofficeService(
|
||||
TikuDbContext dbContext,
|
||||
IOperationAuditService auditService) : IBackofficeService
|
||||
IOperationAuditService auditService,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackofficeService
|
||||
{
|
||||
private static readonly BuiltinPermission[] BuiltinPermissions =
|
||||
[
|
||||
@@ -63,7 +64,11 @@ internal sealed class BackofficeService(
|
||||
}
|
||||
|
||||
await EnsureCatalogAsync(cancellationToken);
|
||||
var permissionCodes = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray();
|
||||
var permissionCodes = await FilterTenantPermissionCodesAsync(
|
||||
access.TenantId.Value,
|
||||
access.TenantPermissions,
|
||||
CapabilityOperation.Read,
|
||||
cancellationToken);
|
||||
var menus = await LoadEffectiveMenusAsync(
|
||||
BackendPermissionArea.Tenant,
|
||||
permissionCodes,
|
||||
@@ -100,10 +105,18 @@ internal sealed class BackofficeService(
|
||||
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
|
||||
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var enabledPermissionCodes = await FilterTenantPermissionCodesAsync(
|
||||
tenantId,
|
||||
permissions.Select(item => item.Code),
|
||||
CapabilityOperation.Read,
|
||||
cancellationToken);
|
||||
permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)).ToArray();
|
||||
var menus = await dbContext.BackendMenus.AsNoTracking()
|
||||
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
|
||||
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
menus = menus.Where(item => item.PermissionCode is null ||
|
||||
enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)).ToArray();
|
||||
return new BackofficeBootstrap(
|
||||
permissions.Select(ToPermissionItem).ToArray(),
|
||||
menus.Select(ToMenuItem).ToArray(),
|
||||
@@ -350,6 +363,19 @@ internal sealed class BackofficeService(
|
||||
var normalizedPermissions = NormalizeCodes(permissionCodes);
|
||||
var normalizedMenus = NormalizeCodes(menuCodes);
|
||||
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
|
||||
foreach (var permissionCode in normalizedPermissions)
|
||||
{
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
tenantId,
|
||||
ResolveModuleCode(permissionCode),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new BackofficeException(
|
||||
"One or more permissions belong to a module unavailable to this tenant.",
|
||||
"capability_not_available");
|
||||
}
|
||||
}
|
||||
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
|
||||
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||
@@ -401,6 +427,33 @@ internal sealed class BackofficeService(
|
||||
return menus.Select(ToMenuItem).ToArray();
|
||||
}
|
||||
|
||||
private async Task<string[]> FilterTenantPermissionCodesAsync(
|
||||
Guid tenantId,
|
||||
IEnumerable<string> permissionCodes,
|
||||
CapabilityOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enabled = new List<string>();
|
||||
foreach (var permissionCode in permissionCodes.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal))
|
||||
{
|
||||
if (await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
tenantId,
|
||||
ResolveModuleCode(permissionCode),
|
||||
operation,
|
||||
cancellationToken))
|
||||
{
|
||||
enabled.Add(permissionCode);
|
||||
}
|
||||
}
|
||||
return enabled.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveModuleCode(string permissionCode)
|
||||
{
|
||||
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await dbContext.BackendMenus.CountAsync(
|
||||
|
||||
@@ -30,8 +30,9 @@ public sealed class TaxonomyService(
|
||||
}
|
||||
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"List platform taxonomy with tenant extensions",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"List platform taxonomy with tenant extensions", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -85,8 +86,9 @@ public sealed class TaxonomyService(
|
||||
_ => throw new InvalidOperationException("A parent source is required when parentId is provided.")
|
||||
};
|
||||
parent = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Validate taxonomy extension parent ownership",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -137,8 +139,9 @@ public sealed class TaxonomyService(
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Resolve platform taxonomy owner",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService),
|
||||
"Resolve platform taxonomy owner", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) => await provider.GetRequiredService<TikuDbContext>()
|
||||
.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||
|
||||
@@ -40,7 +40,9 @@ public sealed class DirectContentService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var question = new Question
|
||||
{
|
||||
@@ -57,7 +59,10 @@ public sealed class DirectContentService(
|
||||
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ using Tiku.Infrastructure.StudyContent;
|
||||
using Tiku.Infrastructure.TenantAdmin;
|
||||
using Tiku.Infrastructure.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using StackExchange.Redis;
|
||||
using MassTransit;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -83,6 +86,8 @@ public static class DependencyInjection
|
||||
.AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
|
||||
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
||||
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||
services.AddSingleton<IRedisSecurityStore, NullRedisSecurityStore>();
|
||||
services.AddScoped<ISecurityEventPublisher, NullSecurityEventPublisher>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
|
||||
@@ -121,6 +126,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
@@ -150,4 +156,78 @@ public static class DependencyInjection
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddRedisSecurity(
|
||||
this IServiceCollection services,
|
||||
string connectionString,
|
||||
string environmentName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
var options = ConfigurationOptions.Parse(connectionString);
|
||||
options.AbortOnConnectFail = false;
|
||||
options.ClientName = $"tiku-{environmentName.ToLowerInvariant()}";
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(options));
|
||||
services.AddSingleton(provider => new RedisSecurityStore(
|
||||
provider.GetRequiredService<IConnectionMultiplexer>(),
|
||||
environmentName,
|
||||
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<RedisSecurityStore>>()));
|
||||
services.AddSingleton<IRedisSecurityStore>(provider => provider.GetRequiredService<RedisSecurityStore>());
|
||||
services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options);
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddReliableMessaging(
|
||||
this IServiceCollection services,
|
||||
MessagingOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!options.IsConfigured)
|
||||
{
|
||||
throw new ArgumentException("A valid RabbitMQ host URI is required.", nameof(options));
|
||||
}
|
||||
|
||||
services.AddMassTransit(registration =>
|
||||
{
|
||||
registration.SetKebabCaseEndpointNameFormatter();
|
||||
registration.ConfigureHealthCheckOptions(health =>
|
||||
{
|
||||
health.Name = "rabbitmq";
|
||||
health.Tags.Add("ready");
|
||||
});
|
||||
registration.AddEntityFrameworkOutbox<TikuDbContext>(outbox =>
|
||||
{
|
||||
outbox.UsePostgres();
|
||||
outbox.UseBusOutbox();
|
||||
outbox.QueryDelay = TimeSpan.FromSeconds(1);
|
||||
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
|
||||
});
|
||||
if (options.ConfigureConsumers)
|
||||
{
|
||||
registration.AddConsumer<SecurityStateChangedConsumer>(consumer =>
|
||||
{
|
||||
consumer.ConcurrentMessageLimit = 1;
|
||||
consumer.UseMessageRetry(retry => retry.Intervals(
|
||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
||||
});
|
||||
registration.AddConfigureEndpointsCallback((context, _, endpoint) =>
|
||||
{
|
||||
endpoint.PrefetchCount = 1;
|
||||
endpoint.ConcurrentMessageLimit = 1;
|
||||
endpoint.UseEntityFrameworkOutbox<TikuDbContext>(context);
|
||||
});
|
||||
}
|
||||
|
||||
registration.UsingRabbitMq((context, configurator) =>
|
||||
{
|
||||
configurator.Host(new Uri(options.Host), options.VirtualHost, host =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Username)) host.Username(options.Username);
|
||||
if (!string.IsNullOrWhiteSpace(options.Password)) host.Password(options.Password);
|
||||
});
|
||||
configurator.ConfigureEndpoints(context);
|
||||
});
|
||||
});
|
||||
services.AddScoped<ISecurityEventPublisher, MassTransitSecurityEventPublisher>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ namespace Tiku.Infrastructure.Jobs;
|
||||
internal sealed class BackgroundJobService(
|
||||
TikuDbContext dbContext,
|
||||
IServiceProvider serviceProvider,
|
||||
ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackgroundJobService
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
@@ -24,10 +25,19 @@ internal sealed class BackgroundJobService(
|
||||
CreateBackgroundJobCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedJobType = NormalizeJobType(command.JobType);
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
command.TenantId,
|
||||
ResolveCapabilityModule(normalizedJobType),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant capability does not allow this background job.");
|
||||
}
|
||||
var job = new BackgroundJob
|
||||
{
|
||||
TenantId = command.TenantId,
|
||||
JobType = NormalizeJobType(command.JobType),
|
||||
JobType = normalizedJobType,
|
||||
Payload = command.Payload,
|
||||
RunAfter = command.RunAfter,
|
||||
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
||||
@@ -55,6 +65,19 @@ internal sealed class BackgroundJobService(
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
job.TenantId,
|
||||
ResolveCapabilityModule(job.JobType),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
{
|
||||
job.Status = BackgroundJobStatus.Failed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = "Tenant capability was revoked before job execution.";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
processed++;
|
||||
continue;
|
||||
}
|
||||
job.Status = BackgroundJobStatus.Processing;
|
||||
job.LockedBy = workerId;
|
||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||
@@ -64,8 +87,9 @@ internal sealed class BackgroundJobService(
|
||||
try
|
||||
{
|
||||
var result = await tenantExecutionScope.ExecuteAsync(
|
||||
job.TenantId,
|
||||
$"Background job {job.JobType}",
|
||||
new SystemScopeRequest(
|
||||
job.TenantId, SystemScopeCallerType.Worker, workerId,
|
||||
$"Background job {job.JobType}", job.Id.ToString("N")),
|
||||
(provider, token) => ProcessCoreAsync(provider, job, token),
|
||||
cancellationToken);
|
||||
job.Status = BackgroundJobStatus.Succeeded;
|
||||
@@ -328,6 +352,15 @@ internal sealed class BackgroundJobService(
|
||||
return jobType.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ResolveCapabilityModule(string jobType) => jobType switch
|
||||
{
|
||||
"content_export" or "content_import" or "asset_security_scan" => "content",
|
||||
"statistics_aggregation" => "dashboard",
|
||||
"commerce_reconciliation" => "commerce",
|
||||
"tenant_domain_recheck" => "settings",
|
||||
_ => "job"
|
||||
};
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
{
|
||||
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
@@ -1024,8 +1024,9 @@ public sealed class LearningActivityService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Lock published question versions for a new practice session",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
||||
"Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -1076,8 +1077,9 @@ public sealed class LearningActivityService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read locked question versions for a tenant practice session",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
||||
"Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
13
Tiku.Infrastructure/Messaging/MessagingOptions.cs
Normal file
13
Tiku.Infrastructure/Messaging/MessagingOptions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
public sealed class MessagingOptions
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public string VirtualHost { get; set; } = "/";
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool ConfigureConsumers { get; set; }
|
||||
|
||||
public bool IsConfigured => Uri.TryCreate(Host, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme is "rabbitmq" or "amqp" or "amqps";
|
||||
}
|
||||
70
Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs
Normal file
70
Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using MassTransit;
|
||||
using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
public interface ISecurityEventPublisher
|
||||
{
|
||||
Task AuthorizationChangedAsync(
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
string changeKind,
|
||||
long version,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task CapabilityChangedAsync(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
string changeKind,
|
||||
long version,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task MembershipChangedAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
string previousStatus,
|
||||
string currentStatus,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class NullSecurityEventPublisher : ISecurityEventPublisher
|
||||
{
|
||||
public Task AuthorizationChangedAsync(
|
||||
Guid? tenantId, Guid? userId, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task CapabilityChangedAsync(
|
||||
Guid tenantId, string moduleCode, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task MembershipChangedAsync(
|
||||
Guid tenantId, Guid userId, string previousStatus, string currentStatus,
|
||||
string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class MassTransitSecurityEventPublisher(IPublishEndpoint publishEndpoint) : ISecurityEventPublisher
|
||||
{
|
||||
public Task AuthorizationChangedAsync(
|
||||
Guid? tenantId, Guid? userId, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(new AuthorizationStateChangedV1(
|
||||
Guid.NewGuid(), tenantId, userId, changeKind, version,
|
||||
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||
|
||||
public Task CapabilityChangedAsync(
|
||||
Guid tenantId, string moduleCode, string changeKind, long version,
|
||||
string correlationId, CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(new TenantCapabilityChangedV1(
|
||||
Guid.NewGuid(), tenantId, moduleCode, changeKind, version,
|
||||
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||
|
||||
public Task MembershipChangedAsync(
|
||||
Guid tenantId, Guid userId, string previousStatus, string currentStatus,
|
||||
string correlationId, CancellationToken cancellationToken = default) =>
|
||||
publishEndpoint.Publish(new MembershipLifecycleChangedV1(
|
||||
Guid.NewGuid(), tenantId, userId, previousStatus, currentStatus,
|
||||
DateTimeOffset.UtcNow, correlationId), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MassTransit;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Contracts;
|
||||
|
||||
namespace Tiku.Infrastructure.Messaging;
|
||||
|
||||
internal sealed class SecurityStateChangedConsumer(IRedisSecurityStore redisSecurityStore) :
|
||||
IConsumer<AuthorizationStateChangedV1>,
|
||||
IConsumer<TenantCapabilityChangedV1>,
|
||||
IConsumer<MembershipLifecycleChangedV1>
|
||||
{
|
||||
public Task Consume(ConsumeContext<AuthorizationStateChangedV1> context) =>
|
||||
redisSecurityStore.SetInvalidationVersionAsync(
|
||||
"authorization", context.Message.TenantId, context.Message.UserId,
|
||||
context.Message.Version, context.CancellationToken);
|
||||
|
||||
public Task Consume(ConsumeContext<TenantCapabilityChangedV1> context) =>
|
||||
redisSecurityStore.SetInvalidationVersionAsync(
|
||||
$"capability-{context.Message.ModuleCode}", context.Message.TenantId, null,
|
||||
context.Message.Version, context.CancellationToken);
|
||||
|
||||
public Task Consume(ConsumeContext<MembershipLifecycleChangedV1> context) =>
|
||||
redisSecurityStore.SetInvalidationVersionAsync(
|
||||
"membership", context.Message.TenantId, context.Message.UserId,
|
||||
context.Message.OccurredAt.ToUnixTimeMilliseconds(), context.CancellationToken);
|
||||
}
|
||||
@@ -27,6 +27,56 @@ internal sealed class PlatformSaasPlanConfiguration : IEntityTypeConfiguration<P
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ProductModuleConfiguration : IEntityTypeConfiguration<ProductModule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProductModule> builder)
|
||||
{
|
||||
builder.ConfigureEntity("product_modules");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Description).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.HasIndex(entity => entity.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlanModuleEntitlementConfiguration : IEntityTypeConfiguration<PlanModuleEntitlement>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlanModuleEntitlement> builder)
|
||||
{
|
||||
builder.ConfigureEntity("plan_module_entitlements");
|
||||
builder.Property(entity => entity.PlanCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
|
||||
builder.HasIndex(entity => new { entity.PlanCode, entity.ModuleCode }).IsUnique();
|
||||
builder.HasOne<PlatformSaasPlan>().WithMany()
|
||||
.HasPrincipalKey(entity => entity.Code)
|
||||
.HasForeignKey(entity => entity.PlanCode)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<ProductModule>().WithMany()
|
||||
.HasPrincipalKey(entity => entity.Code)
|
||||
.HasForeignKey(entity => entity.ModuleCode)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantModuleOverrideConfiguration : IEntityTypeConfiguration<TenantModuleOverride>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantModuleOverride> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_module_overrides");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Reason).HasMaxLength(1000);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ModuleCode }).IsUnique();
|
||||
builder.HasOne<ProductModule>().WithMany()
|
||||
.HasPrincipalKey(entity => entity.Code)
|
||||
.HasForeignKey(entity => entity.ModuleCode)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfiguration<TenantBillingProfile>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantBillingProfile> builder)
|
||||
|
||||
@@ -115,6 +115,20 @@ internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration<Ten
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantAuthPolicyConfiguration : IEntityTypeConfiguration<TenantAuthPolicy>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantAuthPolicy> builder)
|
||||
{
|
||||
builder.ToTable("tenant_auth_policies");
|
||||
builder.HasKey(entity => entity.TenantId);
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.AllowExternalStudentSelfRegistration).HasDefaultValue(false);
|
||||
builder.HasOne<Tenant>().WithOne()
|
||||
.HasForeignKey<TenantAuthPolicy>(entity => entity.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration<TenantFrontendConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantFrontendConfig> builder)
|
||||
|
||||
18569
Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs
generated
Normal file
18569
Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDistributedSecurityFoundation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddUniqueConstraint(
|
||||
name: "ak_platform_saas_plans_code",
|
||||
table: "platform_saas_plans",
|
||||
column: "code");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "inbox_state",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
consumer_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
|
||||
received = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
receive_count = table.Column<int>(type: "integer", nullable: false),
|
||||
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
consumed = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_inbox_state", x => x.id);
|
||||
table.UniqueConstraint("ak_inbox_state_message_id_consumer_id", x => new { x.message_id, x.consumer_id });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_state",
|
||||
columns: table => new
|
||||
{
|
||||
outbox_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
|
||||
created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox_state", x => x.outbox_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "product_modules",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_product_modules", x => x.id);
|
||||
table.UniqueConstraint("ak_product_modules_code", x => x.code);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_auth_policies",
|
||||
columns: table => new
|
||||
{
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
allow_external_student_self_registration = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_auth_policies", x => x.tenant_id);
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_auth_policies_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
// Existing tenants retain the historical external-login behavior. New tenants
|
||||
// receive an explicit fail-closed policy when created by PlatformAdminService.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO tenant_auth_policies
|
||||
(tenant_id, allow_external_student_self_registration, created_at, updated_at)
|
||||
SELECT id, TRUE, now(), now()
|
||||
FROM tenants
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_message",
|
||||
columns: table => new
|
||||
{
|
||||
sequence_number = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
enqueue_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
sent_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
headers = table.Column<string>(type: "text", nullable: true),
|
||||
properties = table.Column<string>(type: "text", nullable: true),
|
||||
inbox_message_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
inbox_consumer_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
outbox_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
content_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
message_type = table.Column<string>(type: "text", nullable: false),
|
||||
body = table.Column<string>(type: "text", nullable: false),
|
||||
conversation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
correlation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
initiator_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
request_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
source_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
destination_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
response_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
fault_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox_message", x => x.sequence_number);
|
||||
table.ForeignKey(
|
||||
name: "fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~",
|
||||
columns: x => new { x.inbox_message_id, x.inbox_consumer_id },
|
||||
principalTable: "inbox_state",
|
||||
principalColumns: new[] { "message_id", "consumer_id" });
|
||||
table.ForeignKey(
|
||||
name: "fk_outbox_message_outbox_state_outbox_id",
|
||||
column: x => x.outbox_id,
|
||||
principalTable: "outbox_state",
|
||||
principalColumn: "outbox_id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "plan_module_entitlements",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
plan_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
enabled = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_plan_module_entitlements", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_plan_module_entitlements_platform_saas_plans_plan_code",
|
||||
column: x => x.plan_code,
|
||||
principalTable: "platform_saas_plans",
|
||||
principalColumn: "code",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_plan_module_entitlements_product_modules_module_code",
|
||||
column: x => x.module_code,
|
||||
principalTable: "product_modules",
|
||||
principalColumn: "code",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_module_overrides",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_module_overrides", x => x.id);
|
||||
table.UniqueConstraint("ak_tenant_module_overrides_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_module_overrides_product_modules_module_code",
|
||||
column: x => x.module_code,
|
||||
principalTable: "product_modules",
|
||||
principalColumn: "code",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_module_overrides_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_inbox_state_delivered",
|
||||
table: "inbox_state",
|
||||
column: "delivered");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_enqueue_time",
|
||||
table: "outbox_message",
|
||||
column: "enqueue_time");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_expiration_time",
|
||||
table: "outbox_message",
|
||||
column: "expiration_time");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~",
|
||||
table: "outbox_message",
|
||||
columns: new[] { "inbox_message_id", "inbox_consumer_id", "sequence_number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_outbox_id_sequence_number",
|
||||
table: "outbox_message",
|
||||
columns: new[] { "outbox_id", "sequence_number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_state_created",
|
||||
table: "outbox_state",
|
||||
column: "created");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_plan_module_entitlements_module_code",
|
||||
table: "plan_module_entitlements",
|
||||
column: "module_code");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_plan_module_entitlements_plan_code_module_code",
|
||||
table: "plan_module_entitlements",
|
||||
columns: new[] { "plan_code", "module_code" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_product_modules_code",
|
||||
table: "product_modules",
|
||||
column: "code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_module_overrides_module_code",
|
||||
table: "tenant_module_overrides",
|
||||
column: "module_code");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_module_overrides_tenant_id_module_code",
|
||||
table: "tenant_module_overrides",
|
||||
columns: new[] { "tenant_id", "module_code" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_message");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "plan_module_entitlements");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_auth_policies");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_module_overrides");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "inbox_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "product_modules");
|
||||
|
||||
migrationBuilder.DropUniqueConstraint(
|
||||
name: "ak_platform_saas_plans_code",
|
||||
table: "platform_saas_plans");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,224 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("Consumed")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("consumed");
|
||||
|
||||
b.Property<Guid>("ConsumerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("consumer_id");
|
||||
|
||||
b.Property<DateTime?>("Delivered")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("delivered");
|
||||
|
||||
b.Property<DateTime?>("ExpirationTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expiration_time");
|
||||
|
||||
b.Property<long?>("LastSequenceNumber")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("last_sequence_number");
|
||||
|
||||
b.Property<Guid>("LockId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("lock_id");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("message_id");
|
||||
|
||||
b.Property<int>("ReceiveCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("receive_count");
|
||||
|
||||
b.Property<DateTime>("Received")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("row_version");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_inbox_state");
|
||||
|
||||
b.HasAlternateKey("MessageId", "ConsumerId")
|
||||
.HasName("ak_inbox_state_message_id_consumer_id");
|
||||
|
||||
b.HasIndex("Delivered")
|
||||
.HasDatabaseName("ix_inbox_state_delivered");
|
||||
|
||||
b.ToTable("inbox_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
{
|
||||
b.Property<long>("SequenceNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence_number");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("body");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("content_type");
|
||||
|
||||
b.Property<Guid?>("ConversationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("conversation_id");
|
||||
|
||||
b.Property<Guid?>("CorrelationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("correlation_id");
|
||||
|
||||
b.Property<string>("DestinationAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("destination_address");
|
||||
|
||||
b.Property<DateTime?>("EnqueueTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("enqueue_time");
|
||||
|
||||
b.Property<DateTime?>("ExpirationTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expiration_time");
|
||||
|
||||
b.Property<string>("FaultAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("fault_address");
|
||||
|
||||
b.Property<string>("Headers")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("headers");
|
||||
|
||||
b.Property<Guid?>("InboxConsumerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("inbox_consumer_id");
|
||||
|
||||
b.Property<Guid?>("InboxMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("inbox_message_id");
|
||||
|
||||
b.Property<Guid?>("InitiatorId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("initiator_id");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("message_id");
|
||||
|
||||
b.Property<string>("MessageType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("message_type");
|
||||
|
||||
b.Property<Guid?>("OutboxId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("outbox_id");
|
||||
|
||||
b.Property<string>("Properties")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("properties");
|
||||
|
||||
b.Property<Guid?>("RequestId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("request_id");
|
||||
|
||||
b.Property<string>("ResponseAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("response_address");
|
||||
|
||||
b.Property<DateTime>("SentTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("sent_time");
|
||||
|
||||
b.Property<string>("SourceAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("source_address");
|
||||
|
||||
b.HasKey("SequenceNumber")
|
||||
.HasName("pk_outbox_message");
|
||||
|
||||
b.HasIndex("EnqueueTime")
|
||||
.HasDatabaseName("ix_outbox_message_enqueue_time");
|
||||
|
||||
b.HasIndex("ExpirationTime")
|
||||
.HasDatabaseName("ix_outbox_message_expiration_time");
|
||||
|
||||
b.HasIndex("OutboxId", "SequenceNumber")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_message_outbox_id_sequence_number");
|
||||
|
||||
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~");
|
||||
|
||||
b.ToTable("outbox_message", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
|
||||
{
|
||||
b.Property<Guid>("OutboxId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("outbox_id");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<DateTime?>("Delivered")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("delivered");
|
||||
|
||||
b.Property<long?>("LastSequenceNumber")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("last_sequence_number");
|
||||
|
||||
b.Property<Guid>("LockId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("lock_id");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("row_version");
|
||||
|
||||
b.HasKey("OutboxId")
|
||||
.HasName("pk_outbox_state");
|
||||
|
||||
b.HasIndex("Created")
|
||||
.HasDatabaseName("ix_outbox_state_created");
|
||||
|
||||
b.ToTable("outbox_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -11755,6 +11973,43 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("user_notifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("ModuleCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("module_code");
|
||||
|
||||
b.Property<string>("PlanCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("plan_code");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_plan_module_entitlements");
|
||||
|
||||
b.HasIndex("ModuleCode")
|
||||
.HasDatabaseName("ix_plan_module_entitlements_module_code");
|
||||
|
||||
b.HasIndex("PlanCode", "ModuleCode")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_plan_module_entitlements_plan_code_module_code");
|
||||
|
||||
b.ToTable("plan_module_entitlements", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -12304,6 +12559,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_platform_saas_plans");
|
||||
|
||||
b.HasAlternateKey("Code")
|
||||
.HasName("ak_platform_saas_plans_code");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_platform_saas_plans_code");
|
||||
@@ -12317,6 +12575,66 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.ProductModule", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_product_modules");
|
||||
|
||||
b.HasAlternateKey("Code")
|
||||
.HasName("ak_product_modules_code");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_product_modules_code");
|
||||
|
||||
b.ToTable("product_modules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.TenantBillingProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
@@ -12818,6 +13136,67 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<string>("ModuleCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("module_code");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tenant_module_overrides");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_tenant_module_overrides_tenant_id_id");
|
||||
|
||||
b.HasIndex("ModuleCode")
|
||||
.HasDatabaseName("ix_tenant_module_overrides_module_code");
|
||||
|
||||
b.HasIndex("TenantId", "ModuleCode")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tenant_module_overrides_tenant_id_module_code");
|
||||
|
||||
b.ToTable("tenant_module_overrides", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -13642,6 +14021,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("tenants", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<bool>("AllowExternalStudentSelfRegistration")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("allow_external_student_self_registration");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("TenantId")
|
||||
.HasName("pk_tenant_auth_policies");
|
||||
|
||||
b.ToTable("tenant_auth_policies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
@@ -14618,6 +15027,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("tenant_student_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
{
|
||||
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OutboxId")
|
||||
.HasConstraintName("fk_outbox_message_outbox_state_outbox_id");
|
||||
|
||||
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("InboxMessageId", "InboxConsumerId")
|
||||
.HasPrincipalKey("MessageId", "ConsumerId")
|
||||
.HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
@@ -17550,6 +17973,25 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_user_notifications_users_user_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Platform.ProductModule", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ModuleCode")
|
||||
.HasPrincipalKey("Code")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_plan_module_entitlements_product_modules_module_code");
|
||||
|
||||
b.HasOne("Tiku.Domain.Platform.PlatformSaasPlan", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PlanCode")
|
||||
.HasPrincipalKey("Code")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_plan_module_entitlements_platform_saas_plans_plan_code");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
@@ -17719,6 +18161,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_tenant_invoice_reminders_tenant_invoices_tenant_id_invoice_~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Platform.ProductModule", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ModuleCode")
|
||||
.HasPrincipalKey("Code")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tenant_module_overrides_product_modules_module_code");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tenant_module_overrides_tenants_tenant_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
@@ -17895,6 +18355,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_tenants_users_owner_user_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithOne()
|
||||
.HasForeignKey("Tiku.Domain.Tenancy.TenantAuthPolicy", "TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tenant_auth_policies_tenants_tenant_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
|
||||
@@ -16,6 +16,8 @@ using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using MassTransit;
|
||||
using MassTransit.EntityFrameworkCoreIntegration;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -47,6 +49,7 @@ public sealed class TikuDbContext(
|
||||
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
||||
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
|
||||
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
|
||||
public DbSet<TenantAuthPolicy> TenantAuthPolicies => Set<TenantAuthPolicy>();
|
||||
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
|
||||
public DbSet<TenantExternalProvider> TenantExternalProviders => Set<TenantExternalProvider>();
|
||||
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
|
||||
@@ -178,6 +181,9 @@ public sealed class TikuDbContext(
|
||||
public DbSet<TenantThemeTemplate> TenantThemeTemplates => Set<TenantThemeTemplate>();
|
||||
public DbSet<TenantThemeConfig> TenantThemeConfigs => Set<TenantThemeConfig>();
|
||||
public DbSet<PlatformSaasPlan> PlatformSaasPlans => Set<PlatformSaasPlan>();
|
||||
public DbSet<ProductModule> ProductModules => Set<ProductModule>();
|
||||
public DbSet<PlanModuleEntitlement> PlanModuleEntitlements => Set<PlanModuleEntitlement>();
|
||||
public DbSet<TenantModuleOverride> TenantModuleOverrides => Set<TenantModuleOverride>();
|
||||
public DbSet<TenantBillingProfile> TenantBillingProfiles => Set<TenantBillingProfile>();
|
||||
public DbSet<TenantInvoice> TenantInvoices => Set<TenantInvoice>();
|
||||
public DbSet<TenantInvoiceItem> TenantInvoiceItems => Set<TenantInvoiceItem>();
|
||||
@@ -201,6 +207,12 @@ public sealed class TikuDbContext(
|
||||
modelBuilder.HasPostgresExtension("ltree");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
|
||||
modelBuilder.AddInboxStateEntity();
|
||||
modelBuilder.AddOutboxMessageEntity();
|
||||
modelBuilder.AddOutboxStateEntity();
|
||||
modelBuilder.Entity<InboxState>().ToTable("inbox_state");
|
||||
modelBuilder.Entity<OutboxMessage>().ToTable("outbox_message");
|
||||
modelBuilder.Entity<OutboxState>().ToTable("outbox_state");
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
ValidateTenantModel(modelBuilder);
|
||||
modelBuilder.UseSnakeCaseIdentifiers();
|
||||
|
||||
@@ -10,6 +10,7 @@ using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
|
||||
@@ -152,6 +153,11 @@ internal sealed class PlatformAdminService(
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.Tenants.Add(tenant);
|
||||
dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
AllowExternalStudentSelfRegistration = false
|
||||
});
|
||||
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToTenantItem(tenant, 0, null);
|
||||
@@ -164,7 +170,7 @@ internal sealed class PlatformAdminService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant status update", async dbContext =>
|
||||
return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) =>
|
||||
{
|
||||
var tenant = await dbContext.Tenants
|
||||
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
@@ -181,6 +187,13 @@ internal sealed class PlatformAdminService(
|
||||
ToBillingStatus = tenant.BillingStatus,
|
||||
command.Reason
|
||||
});
|
||||
await provider.GetRequiredService<ISecurityEventPublisher>().AuthorizationChangedAsync(
|
||||
tenant.Id,
|
||||
null,
|
||||
"tenant_status_changed",
|
||||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
$"tenant-status-{tenant.Id:N}",
|
||||
cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||
var expiresAt = await dbContext.TenantSubscriptions
|
||||
@@ -255,7 +268,7 @@ internal sealed class PlatformAdminService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext =>
|
||||
return await ExecuteSystemAsync("platform tenant subscription upsert", async (provider, dbContext) =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
|
||||
@@ -285,6 +298,27 @@ internal sealed class PlatformAdminService(
|
||||
subscription.Status,
|
||||
subscription.ExpiresAt
|
||||
});
|
||||
var moduleCodes = await dbContext.PlanModuleEntitlements.AsNoTracking()
|
||||
.Where(item => item.PlanCode == subscription.PlanCode)
|
||||
.Select(item => item.ModuleCode)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (moduleCodes.Length == 0)
|
||||
{
|
||||
moduleCodes = ["*"];
|
||||
}
|
||||
var eventPublisher = provider.GetRequiredService<ISecurityEventPublisher>();
|
||||
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
foreach (var moduleCode in moduleCodes)
|
||||
{
|
||||
await eventPublisher.CapabilityChangedAsync(
|
||||
command.TenantId,
|
||||
moduleCode,
|
||||
"subscription_changed",
|
||||
version,
|
||||
$"tenant-subscription-{subscription.Id:N}",
|
||||
cancellationToken);
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSubscriptionItem(subscription);
|
||||
}, cancellationToken);
|
||||
@@ -719,11 +753,23 @@ internal sealed class PlatformAdminService(
|
||||
string reason,
|
||||
Func<TikuDbContext, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken);
|
||||
}
|
||||
|
||||
private Task<TResult> ExecuteSystemAsync<TResult>(
|
||||
string reason,
|
||||
Func<IServiceProvider, TikuDbContext, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
null,
|
||||
reason,
|
||||
async (provider, _) => await operation(provider.GetRequiredService<TikuDbContext>()),
|
||||
new SystemScopeRequest(
|
||||
null,
|
||||
SystemScopeCallerType.Platform,
|
||||
nameof(PlatformAdminService),
|
||||
reason,
|
||||
Guid.NewGuid().ToString("N")),
|
||||
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,9 @@ public sealed class QuestionBankQueryService(
|
||||
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
||||
? []
|
||||
: await tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform question banks for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||
"List platform question banks for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -336,8 +337,9 @@ public sealed class QuestionBankQueryService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform questions for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||
"List platform questions for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
@@ -380,8 +382,9 @@ public sealed class QuestionBankQueryService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read platform question versions for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||
"Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
@@ -73,8 +73,9 @@ public sealed class QuestionReferenceService(
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
var ownerTenantId = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Resolve a platform question for an entitled tenant",
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionReferenceService),
|
||||
"Resolve a platform question for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
87
Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs
Normal file
87
Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapabilityAccessEvaluator
|
||||
{
|
||||
public async Task<bool> IsAllowedAsync(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
CapabilityOperation operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = moduleCode.Trim().ToLowerInvariant();
|
||||
var moduleExists = await dbContext.ProductModules.AsNoTracking()
|
||||
.AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken);
|
||||
if (!moduleExists)
|
||||
{
|
||||
// Compatibility while the fixed module catalog is introduced module-by-module.
|
||||
return true;
|
||||
}
|
||||
|
||||
var tenantActive = await dbContext.Tenants.AsNoTracking()
|
||||
.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken);
|
||||
if (!tenantActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var overrideMode = await dbContext.TenantModuleOverrides.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.ModuleCode == normalized &&
|
||||
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||
.Select(item => (TenantModuleOverrideMode?)item.Mode)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (overrideMode == TenantModuleOverrideMode.Disabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var subscription = await dbContext.TenantSubscriptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.Select(item => new { item.PlanCode, item.Status, item.StartsAt, item.ExpiresAt })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (subscription is null || subscription.StartsAt > now || subscription.ExpiresAt <= now)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entitled = overrideMode == TenantModuleOverrideMode.Enabled ||
|
||||
await dbContext.PlanModuleEntitlements.AsNoTracking().AnyAsync(
|
||||
item => item.PlanCode == subscription.PlanCode && item.ModuleCode == normalized && item.Enabled,
|
||||
cancellationToken);
|
||||
if (!entitled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return operation == CapabilityOperation.Read ||
|
||||
subscription.Status is TenantSubscriptionStatus.Trial or TenantSubscriptionStatus.Active;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||
Guid tenantId,
|
||||
CapabilityOperation operation = CapabilityOperation.Read,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var modules = await dbContext.ProductModules.AsNoTracking()
|
||||
.Where(item => item.Status == ProductModuleStatus.Active)
|
||||
.Select(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var enabled = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (await IsAllowedAsync(tenantId, module, operation, cancellationToken))
|
||||
{
|
||||
enabled.Add(module);
|
||||
}
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
public sealed class RedisSecurityConnectionOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
}
|
||||
135
Tiku.Infrastructure/Security/RedisSecurityStore.cs
Normal file
135
Tiku.Infrastructure/Security/RedisSecurityStore.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class RedisSecurityStore(
|
||||
IConnectionMultiplexer connection,
|
||||
string environmentName,
|
||||
ILogger<RedisSecurityStore> logger) : IRedisSecurityStore
|
||||
{
|
||||
private static readonly Meter Meter = new("Tiku.Security.Redis", "1.0.0");
|
||||
private static readonly Counter<long> OperationCounter = Meter.CreateCounter<long>("tiku.redis.security.operations");
|
||||
private static readonly Counter<long> RejectionCounter = Meter.CreateCounter<long>("tiku.redis.rate_limit.rejections");
|
||||
private static readonly Counter<long> ErrorCounter = Meter.CreateCounter<long>("tiku.redis.security.errors");
|
||||
private static readonly Histogram<double> ScriptDuration = Meter.CreateHistogram<double>(
|
||||
"tiku.redis.lua.duration", "ms");
|
||||
private const string ConsumeScript = """
|
||||
local now = redis.call('TIME')
|
||||
local nowMs = now[1] * 1000 + math.floor(now[2] / 1000)
|
||||
local retryAfter = 0
|
||||
for i = 1, #KEYS do
|
||||
local current = tonumber(redis.call('GET', KEYS[i]) or '0')
|
||||
local limit = tonumber(ARGV[(i - 1) * 2 + 1])
|
||||
if current >= limit then
|
||||
local ttl = redis.call('PTTL', KEYS[i])
|
||||
if ttl > retryAfter then retryAfter = ttl end
|
||||
end
|
||||
end
|
||||
if retryAfter > 0 then return {0, retryAfter} end
|
||||
for i = 1, #KEYS do
|
||||
local window = tonumber(ARGV[(i - 1) * 2 + 2])
|
||||
local value = redis.call('INCR', KEYS[i])
|
||||
if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end
|
||||
end
|
||||
return {1, 0}
|
||||
""";
|
||||
|
||||
private readonly string prefix = $"tiku:{Normalize(environmentName)}";
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public async Task<DistributedRateLimitResult> ConsumeAsync(
|
||||
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (buckets.Count == 0)
|
||||
{
|
||||
return new DistributedRateLimitResult(true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
var keys = buckets.Select(bucket => (RedisKey)$"{prefix}:rl:{bucket.Key}").ToArray();
|
||||
var values = buckets
|
||||
.SelectMany(bucket => new RedisValue[] { bucket.PermitLimit, (long)bucket.Window.TotalMilliseconds })
|
||||
.ToArray();
|
||||
var result = (RedisResult[])(await connection.GetDatabase()
|
||||
.ScriptEvaluateAsync(ConsumeScript, keys, values).WaitAsync(cancellationToken))!;
|
||||
var allowed = (long)result[0] == 1;
|
||||
var retryMs = (long)result[1];
|
||||
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
||||
ScriptDuration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||
if (!allowed)
|
||||
{
|
||||
RejectionCounter.Add(1);
|
||||
}
|
||||
return new DistributedRateLimitResult(
|
||||
allowed,
|
||||
retryMs > 0 ? TimeSpan.FromMilliseconds(retryMs) : null);
|
||||
}
|
||||
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||
{
|
||||
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
||||
logger.LogError(exception, "Redis security operation failed closed.");
|
||||
throw new RedisSecurityUnavailableException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.GetDatabase().PingAsync().WaitAsync(cancellationToken);
|
||||
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||
{
|
||||
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetInvalidationVersionAsync(
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
long version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{prefix}:auth-inv:{Normalize(realm)}:{tenantId?.ToString("N") ?? "-"}:{userId?.ToString("N") ?? "-"}";
|
||||
await connection.GetDatabase().StringSetAsync(key, version, TimeSpan.FromDays(2)).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
string.Concat(value.Trim().ToLowerInvariant().Select(character =>
|
||||
char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-'));
|
||||
}
|
||||
|
||||
public sealed class NullRedisSecurityStore : IRedisSecurityStore
|
||||
{
|
||||
public bool IsConfigured => false;
|
||||
|
||||
public Task<DistributedRateLimitResult> ConsumeAsync(
|
||||
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new DistributedRateLimitResult(true));
|
||||
|
||||
public Task<bool> PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
|
||||
public Task SetInvalidationVersionAsync(
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
long version,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed class RedisSecurityUnavailableException(Exception innerException)
|
||||
: Exception("Redis security services are unavailable.", innerException);
|
||||
@@ -1,6 +1,11 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
@@ -9,42 +14,102 @@ public sealed class TenantExecutionScope(
|
||||
ILogger<TenantExecutionScope> logger) : ITenantExecutionScope
|
||||
{
|
||||
public Task ExecuteAsync(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
SystemScopeRequest request,
|
||||
Func<IServiceProvider, CancellationToken, Task> operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ExecuteAsync<object?>(
|
||||
targetTenantId,
|
||||
reason,
|
||||
async (provider, token) =>
|
||||
{
|
||||
await operation(provider, token);
|
||||
return null;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync<object?>(request, async (provider, token) =>
|
||||
{
|
||||
await operation(provider, token);
|
||||
return null;
|
||||
}, cancellationToken);
|
||||
|
||||
public async Task<TResult> ExecuteAsync<TResult>(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
SystemScopeRequest request,
|
||||
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
|
||||
}
|
||||
|
||||
Validate(request, operation);
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var initializer = scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>();
|
||||
initializer.InitializeSystem(targetTenantId, reason);
|
||||
initializer.InitializeSystem(request.TargetTenantId, request.Reason);
|
||||
logger.LogWarning(
|
||||
"Entering audited system tenant scope. TargetTenantId={TargetTenantId} Reason={Reason}",
|
||||
targetTenantId,
|
||||
reason);
|
||||
"Entering audited system scope. CallerType={CallerType} Caller={Caller} TargetTenantId={TargetTenantId} CorrelationId={CorrelationId} Reason={Reason}",
|
||||
request.CallerType,
|
||||
request.Caller,
|
||||
request.TargetTenantId,
|
||||
request.CorrelationId,
|
||||
request.Reason);
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
await using var transaction = dbContext.Database.IsRelational()
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
await WriteAuditAsync(dbContext, request, "system_scope.entered", started, null, null, cancellationToken);
|
||||
try
|
||||
{
|
||||
var result = await operation(scope.ServiceProvider, cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
dbContext, request, "system_scope.completed", started, stopwatch.ElapsedMilliseconds, null, cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.RollbackAsync(CancellationToken.None);
|
||||
dbContext.ChangeTracker.Clear();
|
||||
await WriteAuditAsync(
|
||||
dbContext, request, "system_scope.entered", started, null, null, CancellationToken.None);
|
||||
}
|
||||
await WriteAuditAsync(
|
||||
dbContext, request, "system_scope.failed", started, stopwatch.ElapsedMilliseconds,
|
||||
exception.GetType().Name, CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return await operation(scope.ServiceProvider, cancellationToken);
|
||||
private static void Validate<TResult>(
|
||||
SystemScopeRequest request,
|
||||
Func<IServiceProvider, CancellationToken, Task<TResult>> operation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Caller);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.CorrelationId);
|
||||
}
|
||||
|
||||
private static async Task WriteAuditAsync(
|
||||
TikuDbContext dbContext,
|
||||
SystemScopeRequest request,
|
||||
string action,
|
||||
DateTimeOffset startedAt,
|
||||
long? elapsedMilliseconds,
|
||||
string? failureType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = request.TargetTenantId,
|
||||
Action = action,
|
||||
TargetType = "system_scope",
|
||||
TargetId = request.CorrelationId,
|
||||
Details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
callerType = request.CallerType.ToString(),
|
||||
request.Caller,
|
||||
request.Reason,
|
||||
request.CorrelationId,
|
||||
startedAt,
|
||||
elapsedMilliseconds,
|
||||
failureType
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||||
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
||||
@@ -28,7 +29,8 @@ public sealed class TenantAdminDirectService(
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
INotificationProvider notificationProvider,
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IAuthSessionStore sessionStore) : ITenantAdminDirectService
|
||||
IAuthSessionStore sessionStore,
|
||||
ISecurityEventPublisher securityEventPublisher) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
TenantAdminActor actor,
|
||||
@@ -1154,6 +1156,7 @@ public sealed class TenantAdminDirectService(
|
||||
}
|
||||
|
||||
var isNew = membership is null;
|
||||
var previousStatus = membership?.Status.ToString() ?? "none";
|
||||
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
|
||||
{
|
||||
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
|
||||
@@ -1182,6 +1185,9 @@ public sealed class TenantAdminDirectService(
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
|
||||
await securityEventPublisher.MembershipChangedAsync(
|
||||
actor.TenantId, user.Id, previousStatus, status.ToString(),
|
||||
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
}
|
||||
@@ -1206,9 +1212,13 @@ public sealed class TenantAdminDirectService(
|
||||
}
|
||||
|
||||
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
|
||||
var previousStatus = membership.Status;
|
||||
membership.Status = MembershipStatus.Disabled;
|
||||
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
|
||||
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
|
||||
await securityEventPublisher.MembershipChangedAsync(
|
||||
actor.TenantId, membership.UserId, previousStatus.ToString(), MembershipStatus.Disabled.ToString(),
|
||||
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Contracts\Tiku.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -16,6 +17,11 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
<PackageReference Include="MassTransit" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||
<PackageReference Include="MassTransit.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
|
||||
Reference in New Issue
Block a user