(bugfix): redundant users.JwtTokensJson, fluent migrator improvements

This commit is contained in:
Maksym Sadovnychyy 2026-04-26 13:49:45 +02:00
parent 86a31999bf
commit c6d5b3fd1e
8 changed files with 58 additions and 34 deletions

View File

@ -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). 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 Vaults 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 ## [3.3.13] - 2026-04-26
### Fixed ### Fixed
@ -29,13 +39,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added ### 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. - **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 ### 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). - **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 revisions `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`, `TwoFactorSharedKey`, and optional `JwtTokensJson` for additive repair. Still **ADD COLUMN IF NOT EXISTS** only (no DROP). - **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. - **ICertsEngineConfiguration / ISchemaSyncService:** Clarified that add-only sync is recommended and describes the no-DROP guarantee.
## [3.3.10] - 2026-04-26 ## [3.3.10] - 2026-04-26

View File

@ -3,13 +3,13 @@ using MaksIT.Core.Abstractions.Dto;
namespace MaksIT.CertsUI.Engine.Dto.Identity; namespace MaksIT.CertsUI.Engine.Dto.Identity;
/// <summary> /// <summary>
/// PostgreSQL <c>users</c> row (Linq2DB). JWT sessions live in <c>jwt_tokens</c>; <see cref="JwtTokens"/> is not a mapped column. /// PostgreSQL <c>users</c> row (Linq2DB). JWT sessions are rows in <c>jwt_tokens</c>; <see cref="JwtTokens"/> is loaded separately, not a column on <c>users</c>.
/// A legacy <c>JwtTokensJson</c> column may still exist on the table for expand-only migrations; it is not mapped here.
/// </summary> /// </summary>
public class UserDto : DtoDocumentBase<Guid> { public class UserDto : DtoDocumentBase<Guid> {
public required string Name { get; set; } public required string Name { get; set; }
public required string Salt { get; set; } public required string Salt { get; set; }
public required string Hash { get; set; } public required string Hash { get; set; }
public DateTime LastLoginUtc { get; set; } public DateTime LastLoginUtc { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public string? TwoFactorSharedKey { get; set; } public string? TwoFactorSharedKey { get; set; }

View File

@ -25,7 +25,6 @@ public class BaselineCertsSchema : Migration {
.WithColumn("Name").AsCustom("text").NotNullable() .WithColumn("Name").AsCustom("text").NotNullable()
.WithColumn("Salt").AsCustom("text").NotNullable() .WithColumn("Salt").AsCustom("text").NotNullable()
.WithColumn("Hash").AsCustom("text").NotNullable() .WithColumn("Hash").AsCustom("text").NotNullable()
.WithColumn("JwtTokensJson").AsCustom("text").NotNullable()
.WithColumn("LastLoginUtc").AsDateTimeOffset().NotNullable(); .WithColumn("LastLoginUtc").AsDateTimeOffset().NotNullable();
Create.Index("IX_users_Name").OnTable("users").OnColumn("Name").Unique(); Create.Index("IX_users_Name").OnTable("users").OnColumn("Name").Unique();

View File

@ -4,8 +4,8 @@ using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations; namespace MaksIT.CertsUI.Engine.FluentMigrations;
/// <summary> /// <summary>
/// Normalizes JWT refresh/access tokens into <c>jwt_tokens</c> (one row per token). Legacy <c>users.JwtTokensJson</c> is retained /// Normalizes JWT refresh/access tokens into <c>jwt_tokens</c> (one row per token).
/// (expand-only policy); the app reads <c>jwt_tokens</c> only. /// If <c>users.JwtTokensJson</c> exists (older databases), rows are copied from that JSON; otherwise this step only creates <c>jwt_tokens</c>.
/// </summary> /// </summary>
[Migration(20260418100000)] [Migration(20260418100000)]
public class JwtTokensTableMigrateFromJson : Migration { 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_Token").OnTable("jwt_tokens").OnColumn("Token");
Create.Index("IX_jwt_tokens_RefreshToken").OnTable("jwt_tokens").OnColumn("RefreshToken"); Create.Index("IX_jwt_tokens_RefreshToken").OnTable("jwt_tokens").OnColumn("RefreshToken");
Execute.Sql(""" if (Schema.Table("users").Column("JwtTokensJson").Exists()) {
INSERT INTO "jwt_tokens" ("Id", "UserId", "Token", "RefreshToken", "IssuedAt", "ExpiresAt", "RefreshTokenExpiresAt", "IsRevoked") Execute.Sql("""
SELECT INSERT INTO "jwt_tokens" ("Id", "UserId", "Token", "RefreshToken", "IssuedAt", "ExpiresAt", "RefreshTokenExpiresAt", "IsRevoked")
(elem->>'Id')::uuid, SELECT
u."Id", (elem->>'Id')::uuid,
COALESCE(elem->>'Token', ''), u."Id",
COALESCE(elem->>'RefreshToken', ''), COALESCE(elem->>'Token', ''),
(elem->>'IssuedAt')::timestamptz, COALESCE(elem->>'RefreshToken', ''),
(elem->>'ExpiresAt')::timestamptz, (elem->>'IssuedAt')::timestamptz,
(elem->>'RefreshTokenExpiresAt')::timestamptz, (elem->>'ExpiresAt')::timestamptz,
COALESCE((elem->>'IsRevoked')::boolean, false) (elem->>'RefreshTokenExpiresAt')::timestamptz,
FROM "users" u COALESCE((elem->>'IsRevoked')::boolean, false)
CROSS JOIN LATERAL json_array_elements( FROM "users" u
CASE WHEN u."JwtTokensJson" IS NULL OR btrim(u."JwtTokensJson"::text) = '' THEN '[]'::json CROSS JOIN LATERAL json_array_elements(
ELSE u."JwtTokensJson"::json END CASE WHEN u."JwtTokensJson" IS NULL OR btrim(u."JwtTokensJson"::text) = '' THEN '[]'::json
) AS elem ELSE u."JwtTokensJson"::json END
WHERE (elem->>'Id') IS NOT NULL ) AS elem
"""); WHERE (elem->>'Id') IS NOT NULL
""");
}
} }
public override void Down() => public override void Down() =>

View File

@ -3,15 +3,13 @@ using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations; namespace MaksIT.CertsUI.Engine.FluentMigrations;
/// <summary> /// <summary>
/// Databases that already applied <see cref="JwtTokensTableMigrateFromJson"/> when it still dropped <c>JwtTokensJson</c> /// Previously re-added <c>users.JwtTokensJson</c> when a prior migration had dropped that JSON column.
/// get the column back (empty default). Expand-only: we never remove renamed/legacy columns in <c>Up()</c>. /// <see cref="DropUsersJwtTokensJson"/> removes the column; persisted sessions use <c>jwt_tokens</c> only (same role as Vaults <c>JwtToken</c> rows).
/// </summary> /// </summary>
[Migration(20260426120000)] [Migration(20260426120000)]
public class RestoreUsersJwtTokensJsonIfDropped : Migration { public class RestoreUsersJwtTokensJsonIfDropped : Migration {
public override void Up() { public override void Up() {
Execute.Sql(""" // No-op: revision kept so databases that already applied the old DDL remain valid; see DropUsersJwtTokensJson.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "JwtTokensJson" text NOT NULL DEFAULT '';
""");
} }
public override void Down() => public override void Down() =>

View File

@ -0,0 +1,17 @@
using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations;
/// <summary>
/// Drops <c>users.JwtTokensJson</c> when present (old JSON copy of sessions). Sessions remain in <c>jwt_tokens</c> for server-side validation / allowlist behavior.
/// </summary>
[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.");
}

View File

@ -83,7 +83,6 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger<SchemaS
("Name", "text"), ("Name", "text"),
("Salt", "text"), ("Salt", "text"),
("Hash", "text"), ("Hash", "text"),
("JwtTokensJson", "text"),
("LastLoginUtc", "timestamp with time zone"), ("LastLoginUtc", "timestamp with time zone"),
("IsActive", "boolean"), ("IsActive", "boolean"),
("TwoFactorSharedKey", "text"), ("TwoFactorSharedKey", "text"),
@ -168,7 +167,6 @@ public class SchemaSyncService(ICertsEngineConfiguration config, ILogger<SchemaS
if (table.Equals("users", StringComparison.OrdinalIgnoreCase)) { if (table.Equals("users", StringComparison.OrdinalIgnoreCase)) {
if (column.Equals("TwoFactorSharedKey", StringComparison.OrdinalIgnoreCase)) return false; if (column.Equals("TwoFactorSharedKey", StringComparison.OrdinalIgnoreCase)) return false;
if (column.Equals("JwtTokensJson", StringComparison.OrdinalIgnoreCase)) return false;
} }
return true; return true;

View File

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