From c6d5b3fd1e196a90ab975704f6664ad843f566ec Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sun, 26 Apr 2026 13:49:45 +0200 Subject: [PATCH] (bugfix): redundant users.JwtTokensJson, fluent migrator improvements --- CHANGELOG.md | 16 +++++-- .../Dto/Identity/UserDto.cs | 4 +- .../20260415100000_BaselineCertsSchema.cs | 1 - ...418100000_JwtTokensTableMigrateFromJson.cs | 42 ++++++++++--------- ...0000_RestoreUsersJwtTokensJsonIfDropped.cs | 8 ++-- .../20260426140000_DropUsersJwtTokensJson.cs | 17 ++++++++ .../Infrastructure/SchemaSyncService.cs | 2 - src/MaksIT.CertsUI/MaksIT.CertsUI.csproj | 2 +- 8 files changed, 58 insertions(+), 34 deletions(-) create mode 100644 src/MaksIT.CertsUI.Engine/FluentMigrations/20260426140000_DropUsersJwtTokensJson.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b6747..25363ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.14] - 2026-04-26 + +### Fixed + +- **Identity / PostgreSQL:** Removed redundant `users.JwtTokensJson` (historical JSON blob of sessions on the user row). **Server-side session allowlist semantics are unchanged:** issued sessions remain rows in `jwt_tokens` and are validated the same way as in Vault’s persisted `JwtToken` model—only the duplicate JSON encoding was dropped. New `users` inserts no longer hit `23502` on that column. FluentMigrator `DropUsersJwtTokensJson` (`20260426140000`) drops the column when present; the baseline no longer creates it; `JwtTokensTableMigrateFromJson` copies from JSON only if that column still exists (upgrades from older DBs). + +### Changed + +- **FluentMigrator:** `RestoreUsersJwtTokensJsonIfDropped` (`20260426120000`) is now a no-op (revision kept for databases that already applied it). Session material is stored only in `jwt_tokens`, not duplicated as JSON on `users`. + ## [3.3.13] - 2026-04-26 ### Fixed @@ -29,13 +39,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- **Database:** FluentMigrator `RestoreUsersJwtTokensJsonIfDropped` (`20260426120000`) restores `users.JwtTokensJson` with `ADD COLUMN IF NOT EXISTS` when an older database had it removed by a prior `JwtTokensTableMigrateFromJson` revision. +- **Database:** FluentMigrator `RestoreUsersJwtTokensJsonIfDropped` (`20260426120000`) initially re-added `users.JwtTokensJson` with `ADD COLUMN IF NOT EXISTS` for databases that had dropped it under an older `JwtTokensTableMigrateFromJson` revision. **Superseded in 3.3.14:** that revision is a no-op and `DropUsersJwtTokensJson` drops the column; tokens stay in `jwt_tokens` only. - **Helm / config:** `certsServerConfig.configuration.certsUIEngineConfiguration.autoSyncSchema` (default `true`) is rendered into server `appsettings.json` so add-only schema sync runs on every startup unless explicitly disabled. ### Changed -- **Startup schema policy:** Documented expand-only expectations — FluentMigrator `Up()` should add tables/columns; avoid dropping renamed or legacy columns in `Up()`. `JwtTokensTableMigrateFromJson` no longer drops `JwtTokensJson` (tokens remain in `jwt_tokens`; legacy JSON column may remain for audit). -- **Schema sync:** `AutoSyncSchema` defaults to **true** in repo `appsettings.json`; `SchemaSyncService` desired map includes `users.IsActive`, `TwoFactorSharedKey`, and optional `JwtTokensJson` for additive repair. Still **ADD COLUMN IF NOT EXISTS** only (no DROP). +- **Startup schema policy:** Documented expand-only expectations — FluentMigrator `Up()` should add tables/columns; avoid dropping renamed columns in routine `Up()` without an explicit follow-up plan. `JwtTokensTableMigrateFromJson` no longer drops `JwtTokensJson` in that revision’s `Up()` (tokens are normalized into `jwt_tokens`). **3.3.14** removes `JwtTokensJson` from the live schema via `DropUsersJwtTokensJson`. +- **Schema sync:** `AutoSyncSchema` defaults to **true** in repo `appsettings.json`; `SchemaSyncService` desired map includes `users.IsActive` and `TwoFactorSharedKey`. **3.3.14** stops treating `JwtTokensJson` as a desired column. Still **ADD COLUMN IF NOT EXISTS** only (no DROP in sync). - **ICertsEngineConfiguration / ISchemaSyncService:** Clarified that add-only sync is recommended and describes the no-DROP guarantee. ## [3.3.10] - 2026-04-26 diff --git a/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs b/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs index 177037c..dc85f63 100644 --- a/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs +++ b/src/MaksIT.CertsUI.Engine/Dto/Identity/UserDto.cs @@ -3,13 +3,13 @@ using MaksIT.Core.Abstractions.Dto; namespace MaksIT.CertsUI.Engine.Dto.Identity; /// -/// PostgreSQL users row (Linq2DB). JWT sessions live in jwt_tokens; is not a mapped column. -/// A legacy JwtTokensJson column may still exist on the table for expand-only migrations; it is not mapped here. +/// PostgreSQL users row (Linq2DB). JWT sessions are rows in jwt_tokens; is loaded separately, not a column on users. /// public class UserDto : DtoDocumentBase { public required string Name { get; set; } public required string Salt { get; set; } public required string Hash { get; set; } + public DateTime LastLoginUtc { get; set; } public bool IsActive { get; set; } = true; public string? TwoFactorSharedKey { get; set; } diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs index d0ce63c..9c28a95 100644 --- a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs +++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260415100000_BaselineCertsSchema.cs @@ -25,7 +25,6 @@ public class BaselineCertsSchema : Migration { .WithColumn("Name").AsCustom("text").NotNullable() .WithColumn("Salt").AsCustom("text").NotNullable() .WithColumn("Hash").AsCustom("text").NotNullable() - .WithColumn("JwtTokensJson").AsCustom("text").NotNullable() .WithColumn("LastLoginUtc").AsDateTimeOffset().NotNullable(); Create.Index("IX_users_Name").OnTable("users").OnColumn("Name").Unique(); diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs index 1d23435..9de2f13 100644 --- a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs +++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260418100000_JwtTokensTableMigrateFromJson.cs @@ -4,8 +4,8 @@ using FluentMigrator; namespace MaksIT.CertsUI.Engine.FluentMigrations; /// -/// Normalizes JWT refresh/access tokens into jwt_tokens (one row per token). Legacy users.JwtTokensJson is retained -/// (expand-only policy); the app reads jwt_tokens only. +/// Normalizes JWT refresh/access tokens into jwt_tokens (one row per token). +/// If users.JwtTokensJson exists (older databases), rows are copied from that JSON; otherwise this step only creates jwt_tokens. /// [Migration(20260418100000)] public class JwtTokensTableMigrateFromJson : Migration { @@ -29,24 +29,26 @@ public class JwtTokensTableMigrateFromJson : Migration { Create.Index("IX_jwt_tokens_Token").OnTable("jwt_tokens").OnColumn("Token"); Create.Index("IX_jwt_tokens_RefreshToken").OnTable("jwt_tokens").OnColumn("RefreshToken"); - Execute.Sql(""" - INSERT INTO "jwt_tokens" ("Id", "UserId", "Token", "RefreshToken", "IssuedAt", "ExpiresAt", "RefreshTokenExpiresAt", "IsRevoked") - SELECT - (elem->>'Id')::uuid, - u."Id", - COALESCE(elem->>'Token', ''), - COALESCE(elem->>'RefreshToken', ''), - (elem->>'IssuedAt')::timestamptz, - (elem->>'ExpiresAt')::timestamptz, - (elem->>'RefreshTokenExpiresAt')::timestamptz, - COALESCE((elem->>'IsRevoked')::boolean, false) - FROM "users" u - CROSS JOIN LATERAL json_array_elements( - CASE WHEN u."JwtTokensJson" IS NULL OR btrim(u."JwtTokensJson"::text) = '' THEN '[]'::json - ELSE u."JwtTokensJson"::json END - ) AS elem - WHERE (elem->>'Id') IS NOT NULL - """); + if (Schema.Table("users").Column("JwtTokensJson").Exists()) { + Execute.Sql(""" + INSERT INTO "jwt_tokens" ("Id", "UserId", "Token", "RefreshToken", "IssuedAt", "ExpiresAt", "RefreshTokenExpiresAt", "IsRevoked") + SELECT + (elem->>'Id')::uuid, + u."Id", + COALESCE(elem->>'Token', ''), + COALESCE(elem->>'RefreshToken', ''), + (elem->>'IssuedAt')::timestamptz, + (elem->>'ExpiresAt')::timestamptz, + (elem->>'RefreshTokenExpiresAt')::timestamptz, + COALESCE((elem->>'IsRevoked')::boolean, false) + FROM "users" u + CROSS JOIN LATERAL json_array_elements( + CASE WHEN u."JwtTokensJson" IS NULL OR btrim(u."JwtTokensJson"::text) = '' THEN '[]'::json + ELSE u."JwtTokensJson"::json END + ) AS elem + WHERE (elem->>'Id') IS NOT NULL + """); + } } public override void Down() => diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs index 680ca71..3c8aa80 100644 --- a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs +++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426120000_RestoreUsersJwtTokensJsonIfDropped.cs @@ -3,15 +3,13 @@ using FluentMigrator; namespace MaksIT.CertsUI.Engine.FluentMigrations; /// -/// Databases that already applied when it still dropped JwtTokensJson -/// get the column back (empty default). Expand-only: we never remove renamed/legacy columns in Up(). +/// Previously re-added users.JwtTokensJson when a prior migration had dropped that JSON column. +/// removes the column; persisted sessions use jwt_tokens only (same role as Vault’s JwtToken rows). /// [Migration(20260426120000)] public class RestoreUsersJwtTokensJsonIfDropped : Migration { public override void Up() { - Execute.Sql(""" - ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "JwtTokensJson" text NOT NULL DEFAULT ''; - """); + // No-op: revision kept so databases that already applied the old DDL remain valid; see DropUsersJwtTokensJson. } public override void Down() => diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426140000_DropUsersJwtTokensJson.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426140000_DropUsersJwtTokensJson.cs new file mode 100644 index 0000000..0a9db8b --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260426140000_DropUsersJwtTokensJson.cs @@ -0,0 +1,17 @@ +using FluentMigrator; + +namespace MaksIT.CertsUI.Engine.FluentMigrations; + +/// +/// Drops users.JwtTokensJson when present (old JSON copy of sessions). Sessions remain in jwt_tokens for server-side validation / allowlist behavior. +/// +[Migration(20260426140000)] +public class DropUsersJwtTokensJson : Migration { + public override void Up() { + if (Schema.Table("users").Column("JwtTokensJson").Exists()) + Delete.Column("JwtTokensJson").FromTable("users"); + } + + public override void Down() => + throw new NotSupportedException("Down is not supported; restore from backup if required."); +} diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs index 5d46e4a..88e9a7e 100644 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs @@ -83,7 +83,6 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger - 3.3.13 + 3.3.14 net10.0 enable enable