(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).
## [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
### 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 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` 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

View File

@ -3,13 +3,13 @@ using MaksIT.Core.Abstractions.Dto;
namespace MaksIT.CertsUI.Engine.Dto.Identity;
/// <summary>
/// PostgreSQL <c>users</c> row (Linq2DB). JWT sessions live in <c>jwt_tokens</c>; <see cref="JwtTokens"/> is not a mapped column.
/// A legacy <c>JwtTokensJson</c> column may still exist on the table for expand-only migrations; it is not mapped here.
/// 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>.
/// </summary>
public class UserDto : DtoDocumentBase<Guid> {
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; }

View File

@ -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();

View File

@ -4,8 +4,8 @@ using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations;
/// <summary>
/// Normalizes JWT refresh/access tokens into <c>jwt_tokens</c> (one row per token). Legacy <c>users.JwtTokensJson</c> is retained
/// (expand-only policy); the app reads <c>jwt_tokens</c> only.
/// Normalizes JWT refresh/access tokens into <c>jwt_tokens</c> (one row per token).
/// If <c>users.JwtTokensJson</c> exists (older databases), rows are copied from that JSON; otherwise this step only creates <c>jwt_tokens</c>.
/// </summary>
[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() =>

View File

@ -3,15 +3,13 @@ using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations;
/// <summary>
/// Databases that already applied <see cref="JwtTokensTableMigrateFromJson"/> when it still dropped <c>JwtTokensJson</c>
/// get the column back (empty default). Expand-only: we never remove renamed/legacy columns in <c>Up()</c>.
/// Previously re-added <c>users.JwtTokensJson</c> when a prior migration had dropped that JSON column.
/// <see cref="DropUsersJwtTokensJson"/> removes the column; persisted sessions use <c>jwt_tokens</c> only (same role as Vaults <c>JwtToken</c> rows).
/// </summary>
[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() =>

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

View File

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