fix: permanent owner password persistence with SeedAudit guard
CI - Build & Test / Backend (.NET) (push) Successful in 28s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s

Root cause: Dual-source architecture for owner password (Gitea secret
ENV_OWNER_PASSWORD vs host .env OWNER_PASSWORD) caused drift when
the DB was ever re-seeded or the volume recreated.

Changes:
- Add SeedAudit entity + migration to track one-time seed operations
- EnsureDatabaseAsync checks SeedAudit BEFORE seeding — owner is never
  re-created even if the Users table is wiped
- Deploy and rollback workflows now read OWNER_PASSWORD from the host's
  persistent .env (single source of truth) instead of Gitea secrets
- compose.yaml documented: OWNER_PASSWORD only used during initial seed
- Cleanup: .gitignore extended for core dumps, changelog/deployment.md
  updated with 2026-06-20 session notes

After this fix the DB is the single source of truth for the owner
password after initial seed. The host .env is the single reference
for the initial value.
This commit is contained in:
2026-06-21 10:15:36 +02:00
parent 2d218853a5
commit f95463ef50
13 changed files with 488 additions and 11 deletions
+15
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Nexus.Api.Data;
@@ -28,6 +29,20 @@ public class NexusUser
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
/// <summary>
/// Tracks one-time seed operations so they are never re-executed — even
/// if the underlying data is deleted. This is the single guard that
/// prevents owner-password drift after DB resets or volume recreations.
/// </summary>
public class SeedAudit
{
[Key]
[MaxLength(80)]
public string Key { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class RefreshToken
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -0,0 +1,336 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260621081500_AddSeedAudit")]
partial class AddSeedAudit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddSeedAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SeedAudit",
columns: table => new
{
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SeedAudit", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SeedAudit");
}
}
}
@@ -214,6 +214,20 @@ namespace Nexus.Api.Migrations
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
+1
View File
@@ -10,6 +10,7 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
public DbSet<NexusUser> Users => Set<NexusUser>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -13,6 +13,8 @@ public static class ApplicationBuilderExtensions
{
/// <summary>
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
/// Uses a <see cref="SeedAudit"/> guard so the owner is never re-created even if all users
/// are deleted — the DB is the single source of truth for the owner password after first seed.
/// </summary>
public static async Task EnsureDatabaseAsync(this WebApplication app)
{
@@ -23,6 +25,11 @@ public static class ApplicationBuilderExtensions
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
const string seedKey = "owner_created";
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
if (alreadySeeded)
return;
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Owner:Password"];
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
@@ -58,6 +65,11 @@ public static class ApplicationBuilderExtensions
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
// Record the seed attempt regardless of whether users already existed.
// This prevents re-seeding even if the Users table is wiped.
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
await db.SaveChangesAsync();
}
}
BIN
View File
Binary file not shown.