context) =>
+ redisSecurityStore.SetInvalidationVersionAsync(
+ "membership", context.Message.TenantId, context.Message.UserId,
+ context.Message.OccurredAt.ToUnixTimeMilliseconds(), context.CancellationToken);
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs
index 7b7df36..59da03c 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs
@@ -27,6 +27,56 @@ internal sealed class PlatformSaasPlanConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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
+{
+ public void Configure(EntityTypeBuilder 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().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.PlanCode)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.ModuleCode)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class TenantModuleOverrideConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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().WithMany()
+ .HasPrincipalKey(entity => entity.Code)
+ .HasForeignKey(entity => entity.ModuleCode)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs
index 2fadca1..2bc3c9b 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs
@@ -115,6 +115,20 @@ internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tenant_auth_policies");
+ builder.HasKey(entity => entity.TenantId);
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.AllowExternalStudentSelfRegistration).HasDefaultValue(false);
+ builder.HasOne().WithOne()
+ .HasForeignKey(entity => entity.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs
new file mode 100644
index 0000000..952db6b
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs
@@ -0,0 +1,18569 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tiku.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Tiku.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(TikuDbContext))]
+ [Migration("20260729012205_AddDistributedSecurityFoundation")]
+ partial class AddDistributedSecurityFoundation
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Consumed")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("consumed");
+
+ b.Property("ConsumerId")
+ .HasColumnType("uuid")
+ .HasColumnName("consumer_id");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("delivered");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiration_time");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint")
+ .HasColumnName("last_sequence_number");
+
+ b.Property("LockId")
+ .HasColumnType("uuid")
+ .HasColumnName("lock_id");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("message_id");
+
+ b.Property("ReceiveCount")
+ .HasColumnType("integer")
+ .HasColumnName("receive_count");
+
+ b.Property("Received")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("received");
+
+ b.Property("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("SequenceNumber")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("sequence_number");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber"));
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("body");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("content_type");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid")
+ .HasColumnName("conversation_id");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid")
+ .HasColumnName("correlation_id");
+
+ b.Property("DestinationAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("destination_address");
+
+ b.Property("EnqueueTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("enqueue_time");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiration_time");
+
+ b.Property("FaultAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("fault_address");
+
+ b.Property("Headers")
+ .HasColumnType("text")
+ .HasColumnName("headers");
+
+ b.Property("InboxConsumerId")
+ .HasColumnType("uuid")
+ .HasColumnName("inbox_consumer_id");
+
+ b.Property("InboxMessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("inbox_message_id");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("initiator_id");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("message_id");
+
+ b.Property("MessageType")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("message_type");
+
+ b.Property("OutboxId")
+ .HasColumnType("uuid")
+ .HasColumnName("outbox_id");
+
+ b.Property("Properties")
+ .HasColumnType("text")
+ .HasColumnName("properties");
+
+ b.Property("RequestId")
+ .HasColumnType("uuid")
+ .HasColumnName("request_id");
+
+ b.Property("ResponseAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("response_address");
+
+ b.Property("SentTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("sent_time");
+
+ b.Property("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("OutboxId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("outbox_id");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("delivered");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint")
+ .HasColumnName("last_sequence_number");
+
+ b.Property("LockId")
+ .HasColumnType("uuid")
+ .HasColumnName("lock_id");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_claims");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_claims_user_id");
+
+ b.ToTable("user_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("provider_display_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("LoginProvider", "ProviderKey")
+ .HasName("pk_user_logins");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_logins_user_id");
+
+ b.ToTable("user_logins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name")
+ .HasName("pk_user_tokens");
+
+ b.ToTable("user_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CategoryType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("category_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("SubjectId")
+ .HasColumnType("uuid")
+ .HasColumnName("subject_id");
+
+ b.Property("SvipQuestionLimit")
+ .HasColumnType("integer")
+ .HasColumnName("svip_question_limit");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_categories");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_categories_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_categories_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_categories_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "SubjectId")
+ .HasDatabaseName("ix_categories_tenant_id_subject_id");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Major", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("StudyTips")
+ .HasColumnType("text")
+ .HasColumnName("study_tips");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_majors");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_majors_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_majors_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_majors_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_majors_tenant_id_school_id");
+
+ b.ToTable("majors", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyModuleId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_module_id");
+
+ b.Property("LegacyParentId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_parent_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("Path")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("path");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property