Add databucket history table, update encryption keys table (#2159)

This commit is contained in:
Leendert de Borst
2026-07-26 21:40:28 +02:00
parent e35f8ee4da
commit 1e9ec2386a
18 changed files with 680 additions and 6244 deletions
@@ -159,6 +159,12 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
/// </summary>
public DbSet<VaultDataBucket> VaultDataBuckets { get; set; }
/// <summary>
/// Gets or sets the VaultDataBucketsHistory DbSet. Superseded bucket revisions kept for backup/rollback,
/// pruned by the bucket retention policy.
/// </summary>
public DbSet<VaultDataBucketsHistory> VaultDataBucketsHistory { get; set; }
/// <summary>
/// Gets or sets the VaultBlobObjects DbSet. These represent encrypted blobs referenced by one or more vault revisions.
/// </summary>
@@ -353,18 +359,30 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
.HasForeignKey(m => m.UserId)
.OnDelete(DeleteBehavior.Cascade);
// Configure VaultDataBucket - Id PK with a revision history per (UserId, Kind).
// Configure VaultDataBucket - the current revision of each (user, category) bucket, updated in place.
modelBuilder.Entity<VaultDataBucket>(builder =>
{
builder.HasKey(e => e.RevisionId);
builder.HasKey(e => new { e.OwnerUserId, e.Category });
builder.Property(e => e.OwnerUserId).HasMaxLength(255);
builder.Property(e => e.Category).HasConversion<string>().HasMaxLength(50);
builder.HasIndex(e => new { e.OwnerUserId, e.Category, e.RevisionNumber }).IsUnique();
builder.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.OwnerUserId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure VaultDataBucketsHistory - superseded bucket revisions, pruned by the bucket retention policy.
modelBuilder.Entity<VaultDataBucketsHistory>(builder =>
{
builder.HasKey(e => new { e.OwnerUserId, e.Category, e.RevisionNumber });
builder.Property(e => e.OwnerUserId).HasMaxLength(255);
builder.Property(e => e.Category).HasConversion<string>().HasMaxLength(50);
builder.HasOne(e => e.Bucket)
.WithMany()
.HasForeignKey(e => new { e.OwnerUserId, e.Category })
.OnDelete(DeleteBehavior.Cascade);
});
// Configure VaultBlobObject - composite key (Hash, UserId).
modelBuilder.Entity<VaultBlobObject>(builder =>
{
File diff suppressed because it is too large Load Diff
@@ -1,179 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddVaultV2Storage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// --- Transform the existing "Vaults" table into "VaultManifests" IN PLACE (preserves every vault row) ---
// Storage-format columns. Add nullable, backfill every existing (legacy) vault explicitly, then enforce
// NOT NULL. This avoids leaving a persistent column default — the storage format is always set explicitly
// by the app.
migrationBuilder.AddColumn<string>(name: "ManifestBlob", table: "Vaults", type: "text", nullable: true);
migrationBuilder.AddColumn<string>(name: "ManifestCiphertextHash", table: "Vaults", type: "character varying(64)", maxLength: 64, nullable: true);
migrationBuilder.AddColumn<string>(name: "StorageFormat", table: "Vaults", type: "character varying(20)", maxLength: 20, nullable: true);
migrationBuilder.Sql("UPDATE \"Vaults\" SET \"StorageFormat\" = 'sqlite-blob' WHERE \"StorageFormat\" IS NULL;");
migrationBuilder.AlterColumn<string>(
name: "StorageFormat",
table: "Vaults",
type: "character varying(20)",
maxLength: 20,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(20)",
oldMaxLength: 20,
oldNullable: true);
// Rename "Vaults" -> "VaultManifests" in place, including constraints and indexes.
migrationBuilder.RenameTable(name: "Vaults", newName: "VaultManifests");
migrationBuilder.RenameColumn(name: "Id", table: "VaultManifests", newName: "RevisionId");
migrationBuilder.RenameColumn(name: "UserId", table: "VaultManifests", newName: "OwnerUserId");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""PK_Vaults"" TO ""PK_VaultManifests"";");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""FK_Vaults_AliasVaultUsers_UserId"" TO ""FK_VaultManifests_AliasVaultUsers_OwnerUserId"";");
migrationBuilder.RenameIndex(name: "IX_Vaults_UserId", table: "VaultManifests", newName: "IX_VaultManifests_OwnerUserId");
// New manifest-lineage columns. Add nullable, backfill every existing row, then enforce NOT NULL.
migrationBuilder.AddColumn<Guid>(name: "ManifestId", table: "VaultManifests", type: "uuid", nullable: true);
migrationBuilder.AddColumn<string>(name: "Category", table: "VaultManifests", type: "character varying(20)", maxLength: 20, nullable: true);
// Every existing revision belongs to the owner's single "Main" manifest. All of an owner's rows must share
// ONE ManifestId (revisions of the same logical manifest), so assign one new GUID per distinct owner.
migrationBuilder.Sql(@"UPDATE ""VaultManifests"" v SET ""ManifestId"" = sub.gid FROM (SELECT DISTINCT ""OwnerUserId"", gen_random_uuid() AS gid FROM ""VaultManifests"") sub WHERE v.""OwnerUserId"" = sub.""OwnerUserId"";");
migrationBuilder.Sql(@"UPDATE ""VaultManifests"" SET ""Category"" = 'Main' WHERE ""Category"" IS NULL;");
migrationBuilder.AlterColumn<Guid>(
name: "ManifestId",
table: "VaultManifests",
type: "uuid",
nullable: false,
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Category",
table: "VaultManifests",
type: "character varying(20)",
maxLength: 20,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(20)",
oldMaxLength: 20,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_VaultManifests_ManifestId_RevisionNumber",
table: "VaultManifests",
columns: new[] { "ManifestId", "RevisionNumber" });
// --- New tables ---
migrationBuilder.CreateTable(
name: "VaultBlobObjects",
columns: table => new
{
Hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
OwnerUserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Category = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
EncryptedData = table.Column<byte[]>(type: "bytea", nullable: false),
SizeBytes = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastReferencedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VaultBlobObjects", x => new { x.Hash, x.OwnerUserId });
table.ForeignKey(
name: "FK_VaultBlobObjects_AliasVaultUsers_OwnerUserId",
column: x => x.OwnerUserId,
principalTable: "AliasVaultUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultDataBuckets",
columns: table => new
{
RevisionId = table.Column<Guid>(type: "uuid", nullable: false),
OwnerUserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Category = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
EncryptedData = table.Column<string>(type: "text", nullable: false),
RevisionNumber = table.Column<long>(type: "bigint", nullable: false),
CiphertextHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VaultDataBuckets", x => x.RevisionId);
table.ForeignKey(
name: "FK_VaultDataBuckets_AliasVaultUsers_OwnerUserId",
column: x => x.OwnerUserId,
principalTable: "AliasVaultUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultBlobReferences",
columns: table => new
{
ManifestRevisionId = table.Column<Guid>(type: "uuid", nullable: false),
BlobHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VaultBlobReferences", x => new { x.ManifestRevisionId, x.BlobHash });
table.ForeignKey(
name: "FK_VaultBlobReferences_VaultManifests_ManifestRevisionId",
column: x => x.ManifestRevisionId,
principalTable: "VaultManifests",
principalColumn: "RevisionId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultBlobObjects_OwnerUserId_Category",
table: "VaultBlobObjects",
columns: new[] { "OwnerUserId", "Category" });
migrationBuilder.CreateIndex(
name: "IX_VaultDataBuckets_OwnerUserId_Category_RevisionNumber",
table: "VaultDataBuckets",
columns: new[] { "OwnerUserId", "Category", "RevisionNumber" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Drop the new tables (VaultBlobReferences first — it FKs into VaultManifests).
migrationBuilder.DropTable(name: "VaultBlobReferences");
migrationBuilder.DropTable(name: "VaultBlobObjects");
migrationBuilder.DropTable(name: "VaultDataBuckets");
// Reverse VaultManifests -> Vaults, in place.
migrationBuilder.DropIndex(name: "IX_VaultManifests_ManifestId_RevisionNumber", table: "VaultManifests");
migrationBuilder.DropColumn(name: "Category", table: "VaultManifests");
migrationBuilder.DropColumn(name: "ManifestId", table: "VaultManifests");
migrationBuilder.RenameIndex(name: "IX_VaultManifests_OwnerUserId", table: "VaultManifests", newName: "IX_Vaults_UserId");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""FK_VaultManifests_AliasVaultUsers_OwnerUserId"" TO ""FK_Vaults_AliasVaultUsers_UserId"";");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""PK_VaultManifests"" TO ""PK_Vaults"";");
migrationBuilder.RenameColumn(name: "OwnerUserId", table: "VaultManifests", newName: "UserId");
migrationBuilder.RenameColumn(name: "RevisionId", table: "VaultManifests", newName: "Id");
migrationBuilder.RenameTable(name: "VaultManifests", newName: "Vaults");
// Drop the storage-format columns added above.
migrationBuilder.DropColumn(name: "StorageFormat", table: "Vaults");
migrationBuilder.DropColumn(name: "ManifestCiphertextHash", table: "Vaults");
migrationBuilder.DropColumn(name: "ManifestBlob", table: "Vaults");
}
}
}
@@ -1,270 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class RestructureVaultManifests : Migration
{
/// <summary>
/// Restructures vault manifest storage into a current + history model:
/// - "VaultManifests" becomes one row per logical manifest (PK = ManifestId) holding the current revision.
/// - Superseded revisions move to the new "VaultManifestsHistory" table (PK = ManifestId, RevisionNumber).
/// - The Category enum column is replaced by an IsRoot boolean; exactly one root manifest per owner is
/// enforced via a partial unique index.
/// - "VaultBlobReferences" is re-keyed from the per-revision RevisionId GUID to (ManifestId, RevisionNumber).
/// </summary>
/// <param name="migrationBuilder">MigrationBuilder instance.</param>
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Replace Category ('Main' | 'SharedFolder') with the IsRoot boolean.
migrationBuilder.AddColumn<bool>(
name: "IsRoot",
table: "VaultManifests",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("""UPDATE "VaultManifests" SET "IsRoot" = ("Category" = 'Main');""");
// 2) Re-key blob references from the per-revision RevisionId GUID to (ManifestId, RevisionNumber)
// while the old VaultManifests revision rows (and their RevisionId column) still exist.
migrationBuilder.DropForeignKey(
name: "FK_VaultBlobReferences_VaultManifests_ManifestRevisionId",
table: "VaultBlobReferences");
migrationBuilder.DropPrimaryKey(
name: "PK_VaultBlobReferences",
table: "VaultBlobReferences");
migrationBuilder.AddColumn<long>(
name: "RevisionNumber",
table: "VaultBlobReferences",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.Sql("""
UPDATE "VaultBlobReferences" r
SET "ManifestRevisionId" = v."ManifestId", "RevisionNumber" = v."RevisionNumber"
FROM "VaultManifests" v
WHERE r."ManifestRevisionId" = v."RevisionId";
""");
migrationBuilder.RenameColumn(
name: "ManifestRevisionId",
table: "VaultBlobReferences",
newName: "ManifestId");
// 3) Create the history table (FK to VaultManifests is added after the current table gets its new PK).
migrationBuilder.CreateTable(
name: "VaultManifestsHistory",
columns: table => new
{
ManifestId = table.Column<Guid>(type: "uuid", nullable: false),
RevisionNumber = table.Column<long>(type: "bigint", nullable: false),
OwnerUserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VaultBlob = table.Column<string>(type: "text", nullable: false),
StorageFormat = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ManifestBlob = table.Column<string>(type: "text", nullable: true),
ManifestCiphertextHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
Version = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
FileSize = table.Column<int>(type: "integer", nullable: false),
Salt = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Verifier = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
CredentialsCount = table.Column<int>(type: "integer", nullable: false),
EmailClaimsCount = table.Column<int>(type: "integer", nullable: false),
EncryptionType = table.Column<string>(type: "text", nullable: false),
EncryptionSettings = table.Column<string>(type: "text", nullable: false),
Client = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VaultManifestsHistory", x => new { x.ManifestId, x.RevisionNumber });
});
// 4) Move every superseded revision into history.
migrationBuilder.Sql("""
INSERT INTO "VaultManifestsHistory" ("ManifestId", "RevisionNumber", "OwnerUserId", "VaultBlob", "StorageFormat", "ManifestBlob", "ManifestCiphertextHash", "Version", "FileSize", "Salt", "Verifier", "CredentialsCount", "EmailClaimsCount", "EncryptionType", "EncryptionSettings", "Client", "CreatedAt", "UpdatedAt")
SELECT "ManifestId", "RevisionNumber", "OwnerUserId", "VaultBlob", "StorageFormat", "ManifestBlob", "ManifestCiphertextHash", "Version", "FileSize", "Salt", "Verifier", "CredentialsCount", "EmailClaimsCount", "EncryptionType", "EncryptionSettings", "Client", "CreatedAt", "UpdatedAt"
FROM (
SELECT v.*, ROW_NUMBER() OVER (PARTITION BY "ManifestId" ORDER BY "RevisionNumber" DESC, "CreatedAt" DESC, "RevisionId" DESC) AS rn
FROM "VaultManifests" v
) ranked
WHERE ranked.rn > 1
ON CONFLICT ("ManifestId", "RevisionNumber") DO NOTHING;
""");
migrationBuilder.Sql("""
DELETE FROM "VaultManifests" v
USING (
SELECT "RevisionId", ROW_NUMBER() OVER (PARTITION BY "ManifestId" ORDER BY "RevisionNumber" DESC, "CreatedAt" DESC, "RevisionId" DESC) AS rn
FROM "VaultManifests"
) ranked
WHERE v."RevisionId" = ranked."RevisionId" AND ranked.rn > 1;
""");
// 5) VaultManifests now holds exactly one row per manifest: re-key it on ManifestId.
migrationBuilder.DropPrimaryKey(
name: "PK_VaultManifests",
table: "VaultManifests");
migrationBuilder.DropIndex(
name: "IX_VaultManifests_ManifestId_RevisionNumber",
table: "VaultManifests");
migrationBuilder.DropIndex(
name: "IX_VaultManifests_OwnerUserId",
table: "VaultManifests");
migrationBuilder.DropColumn(
name: "RevisionId",
table: "VaultManifests");
migrationBuilder.DropColumn(
name: "Category",
table: "VaultManifests");
migrationBuilder.AddPrimaryKey(
name: "PK_VaultManifests",
table: "VaultManifests",
column: "ManifestId");
// Every user has exactly one root manifest.
migrationBuilder.CreateIndex(
name: "UX_VaultManifests_OwnerUserId_Root",
table: "VaultManifests",
column: "OwnerUserId",
unique: true,
filter: "\"IsRoot\"");
// 6) Remaining keys, indexes and foreign keys.
migrationBuilder.AddPrimaryKey(
name: "PK_VaultBlobReferences",
table: "VaultBlobReferences",
columns: new[] { "ManifestId", "RevisionNumber", "BlobHash" });
migrationBuilder.CreateIndex(
name: "IX_VaultManifestsHistory_OwnerUserId",
table: "VaultManifestsHistory",
column: "OwnerUserId");
migrationBuilder.AddForeignKey(
name: "FK_VaultManifestsHistory_VaultManifests_ManifestId",
table: "VaultManifestsHistory",
column: "ManifestId",
principalTable: "VaultManifests",
principalColumn: "ManifestId",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_VaultBlobReferences_VaultManifests_ManifestId",
table: "VaultBlobReferences",
column: "ManifestId",
principalTable: "VaultManifests",
principalColumn: "ManifestId",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultBlobReferences_VaultManifests_ManifestId",
table: "VaultBlobReferences");
migrationBuilder.DropForeignKey(
name: "FK_VaultManifestsHistory_VaultManifests_ManifestId",
table: "VaultManifestsHistory");
migrationBuilder.DropPrimaryKey(
name: "PK_VaultManifests",
table: "VaultManifests");
migrationBuilder.DropIndex(
name: "UX_VaultManifests_OwnerUserId_Root",
table: "VaultManifests");
migrationBuilder.DropPrimaryKey(
name: "PK_VaultBlobReferences",
table: "VaultBlobReferences");
// Restore the per-revision RevisionId PK and the Category column on VaultManifests.
migrationBuilder.Sql("""ALTER TABLE "VaultManifests" ADD COLUMN "RevisionId" uuid NOT NULL DEFAULT gen_random_uuid();""");
migrationBuilder.AddColumn<string>(
name: "Category",
table: "VaultManifests",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "");
migrationBuilder.Sql("""UPDATE "VaultManifests" SET "Category" = CASE WHEN "IsRoot" THEN 'Main' ELSE 'SharedFolder' END;""");
// Move history revisions back into VaultManifests as separate rows.
migrationBuilder.Sql("""
INSERT INTO "VaultManifests" ("RevisionId", "ManifestId", "Category", "IsRoot", "OwnerUserId", "VaultBlob", "StorageFormat", "ManifestBlob", "ManifestCiphertextHash", "Version", "RevisionNumber", "FileSize", "Salt", "Verifier", "CredentialsCount", "EmailClaimsCount", "EncryptionType", "EncryptionSettings", "Client", "CreatedAt", "UpdatedAt")
SELECT gen_random_uuid(), h."ManifestId", m."Category", m."IsRoot", h."OwnerUserId", h."VaultBlob", h."StorageFormat", h."ManifestBlob", h."ManifestCiphertextHash", h."Version", h."RevisionNumber", h."FileSize", h."Salt", h."Verifier", h."CredentialsCount", h."EmailClaimsCount", h."EncryptionType", h."EncryptionSettings", h."Client", h."CreatedAt", h."UpdatedAt"
FROM "VaultManifestsHistory" h
INNER JOIN "VaultManifests" m ON m."ManifestId" = h."ManifestId";
""");
migrationBuilder.DropTable(
name: "VaultManifestsHistory");
migrationBuilder.DropColumn(
name: "IsRoot",
table: "VaultManifests");
// Re-key blob references back to the per-revision RevisionId GUID.
migrationBuilder.Sql("""
UPDATE "VaultBlobReferences" r
SET "ManifestId" = v."RevisionId"
FROM "VaultManifests" v
WHERE v."ManifestId" = r."ManifestId" AND v."RevisionNumber" = r."RevisionNumber";
""");
migrationBuilder.DropColumn(
name: "RevisionNumber",
table: "VaultBlobReferences");
migrationBuilder.RenameColumn(
name: "ManifestId",
table: "VaultBlobReferences",
newName: "ManifestRevisionId");
migrationBuilder.AddPrimaryKey(
name: "PK_VaultManifests",
table: "VaultManifests",
column: "RevisionId");
migrationBuilder.AddPrimaryKey(
name: "PK_VaultBlobReferences",
table: "VaultBlobReferences",
columns: new[] { "ManifestRevisionId", "BlobHash" });
migrationBuilder.CreateIndex(
name: "IX_VaultManifests_ManifestId_RevisionNumber",
table: "VaultManifests",
columns: new[] { "ManifestId", "RevisionNumber" });
migrationBuilder.CreateIndex(
name: "IX_VaultManifests_OwnerUserId",
table: "VaultManifests",
column: "OwnerUserId");
migrationBuilder.AddForeignKey(
name: "FK_VaultBlobReferences_VaultManifests_ManifestRevisionId",
table: "VaultBlobReferences",
column: "ManifestRevisionId",
principalTable: "VaultManifests",
principalColumn: "RevisionId",
onDelete: ReferentialAction.Cascade);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,55 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddVaultKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VaultKeys",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VaultManifestId = table.Column<Guid>(type: "uuid", nullable: true),
KeyType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
WrappedVek = table.Column<string>(type: "text", nullable: false),
Salt = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Verifier = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
EncryptionType = table.Column<string>(type: "text", nullable: false),
EncryptionSettings = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VaultKeys", x => x.Id);
table.ForeignKey(
name: "FK_VaultKeys_AliasVaultUsers_UserId",
column: x => x.UserId,
principalTable: "AliasVaultUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "UX_VaultKeys_UserId_KeyType",
table: "VaultKeys",
columns: new[] { "UserId", "KeyType" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VaultKeys");
}
}
}
@@ -1,171 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class ReshapeVaultKeysForSharing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "UX_VaultKeys_UserId_KeyType",
table: "VaultKeys");
migrationBuilder.AlterColumn<string>(
name: "Verifier",
table: "VaultKeys",
type: "character varying(1000)",
maxLength: 1000,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(1000)",
oldMaxLength: 1000);
migrationBuilder.AlterColumn<string>(
name: "Salt",
table: "VaultKeys",
type: "character varying(100)",
maxLength: 100,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100);
migrationBuilder.AlterColumn<string>(
name: "EncryptionType",
table: "VaultKeys",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "EncryptionSettings",
table: "VaultKeys",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AddColumn<DateTime>(
name: "LastUsedAt",
table: "VaultKeys",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Metadata",
table: "VaultKeys",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "RecipientPublicKeyId",
table: "VaultKeys",
type: "uuid",
nullable: true);
// No default value: WrapScheme is required and set explicitly on every insert. The VaultKeys table is
// empty when this runs (fresh-migration baseline), so a NOT NULL column with no default is safe.
migrationBuilder.AddColumn<string>(
name: "WrapScheme",
table: "VaultKeys",
type: "character varying(30)",
maxLength: 30,
nullable: false);
migrationBuilder.CreateIndex(
name: "IX_VaultKeys_VaultManifestId",
table: "VaultKeys",
column: "VaultManifestId");
migrationBuilder.CreateIndex(
name: "UX_VaultKeys_UserId_KeyType_Manifest",
table: "VaultKeys",
columns: new[] { "UserId", "KeyType", "VaultManifestId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VaultKeys_VaultManifestId",
table: "VaultKeys");
migrationBuilder.DropIndex(
name: "UX_VaultKeys_UserId_KeyType_Manifest",
table: "VaultKeys");
migrationBuilder.DropColumn(
name: "LastUsedAt",
table: "VaultKeys");
migrationBuilder.DropColumn(
name: "Metadata",
table: "VaultKeys");
migrationBuilder.DropColumn(
name: "RecipientPublicKeyId",
table: "VaultKeys");
migrationBuilder.DropColumn(
name: "WrapScheme",
table: "VaultKeys");
migrationBuilder.AlterColumn<string>(
name: "Verifier",
table: "VaultKeys",
type: "character varying(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(1000)",
oldMaxLength: 1000,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Salt",
table: "VaultKeys",
type: "character varying(100)",
maxLength: 100,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "EncryptionType",
table: "VaultKeys",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "EncryptionSettings",
table: "VaultKeys",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "UX_VaultKeys_UserId_KeyType",
table: "VaultKeys",
columns: new[] { "UserId", "KeyType" },
unique: true);
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddVaultManifestName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Name",
table: "VaultManifests",
type: "character varying(255)",
maxLength: 255,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Name",
table: "VaultManifests");
}
}
}
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace AliasServerDb.Migrations
{
[DbContext(typeof(AliasServerDbContext))]
[Migration("20260722145437_AddVaultManifestName")]
partial class AddVaultManifestName
[Migration("20260726192459_AddManifestStorage")]
partial class AddManifestStorage
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -746,6 +746,9 @@ namespace AliasServerDb.Migrations
b.Property<bool>("Disabled")
.HasColumnType("boolean");
b.Property<Guid?>("EncryptionKeyId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -758,6 +761,8 @@ namespace AliasServerDb.Migrations
b.HasIndex("Address")
.IsUnique();
b.HasIndex("EncryptionKeyId");
b.HasIndex("UserId", "CreatedAt");
b.HasIndex("UserId", "Disabled");
@@ -790,9 +795,14 @@ namespace AliasServerDb.Migrations
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("VaultManifestId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("VaultManifestId");
b.HasIndex("UserId", "VaultManifestId", "IsPrimary");
b.ToTable("UserEncryptionKeys");
});
@@ -851,12 +861,11 @@ namespace AliasServerDb.Migrations
modelBuilder.Entity("AliasServerDb.VaultDataBucket", b =>
{
b.Property<Guid>("RevisionId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("OwnerUserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -871,25 +880,49 @@ namespace AliasServerDb.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("RevisionNumber")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("RevisionId");
b.HasIndex("OwnerUserId", "Category", "RevisionNumber")
.IsUnique();
b.HasKey("OwnerUserId", "Category");
b.ToTable("VaultDataBuckets");
});
modelBuilder.Entity("AliasServerDb.VaultDataBucketsHistory", b =>
{
b.Property<string>("OwnerUserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Category")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("RevisionNumber")
.HasColumnType("bigint");
b.Property<string>("CiphertextHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedData")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("OwnerUserId", "Category", "RevisionNumber");
b.ToTable("VaultDataBucketsHistory");
});
modelBuilder.Entity("AliasServerDb.VaultKey", b =>
{
b.Property<Guid>("Id")
@@ -1326,11 +1359,18 @@ namespace AliasServerDb.Migrations
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey")
.WithMany()
.HasForeignKey("EncryptionKeyId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EmailClaims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("EncryptionKey");
b.Navigation("User");
});
@@ -1342,7 +1382,14 @@ namespace AliasServerDb.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasServerDb.VaultManifest", "VaultManifest")
.WithMany()
.HasForeignKey("VaultManifestId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("User");
b.Navigation("VaultManifest");
});
modelBuilder.Entity("AliasServerDb.VaultBlobObject", b =>
@@ -1378,6 +1425,17 @@ namespace AliasServerDb.Migrations
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.VaultDataBucketsHistory", b =>
{
b.HasOne("AliasServerDb.VaultDataBucket", "Bucket")
.WithMany()
.HasForeignKey("OwnerUserId", "Category")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bucket");
});
modelBuilder.Entity("AliasServerDb.VaultKey", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
@@ -0,0 +1,370 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <summary>
/// Adds the manifest storage model to the database.
/// </summary>
public partial class AddManifestStorage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// --- 1. Rename "Vaults" -> "VaultManifests", including its constraints. ---
migrationBuilder.DropIndex(name: "IX_Vaults_UserId", table: "Vaults");
migrationBuilder.RenameTable(name: "Vaults", newName: "VaultManifests");
migrationBuilder.RenameColumn(name: "UserId", table: "VaultManifests", newName: "OwnerUserId");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""PK_Vaults"" TO ""PK_VaultManifests"";");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""FK_Vaults_AliasVaultUsers_UserId"" TO ""FK_VaultManifests_AliasVaultUsers_OwnerUserId"";");
// --- 2. New manifest table columns. ---
migrationBuilder.AddColumn<Guid>(name: "ManifestId", table: "VaultManifests", type: "uuid", nullable: true);
migrationBuilder.AddColumn<string>(name: "Name", table: "VaultManifests", type: "character varying(255)", maxLength: 255, nullable: true);
migrationBuilder.AddColumn<string>(name: "ManifestBlob", table: "VaultManifests", type: "text", nullable: true);
migrationBuilder.AddColumn<string>(name: "ManifestCiphertextHash", table: "VaultManifests", type: "character varying(64)", maxLength: 64, nullable: true);
migrationBuilder.AddColumn<string>(name: "StorageFormat", table: "VaultManifests", type: "character varying(20)", maxLength: 20, nullable: true);
migrationBuilder.AddColumn<bool>(name: "IsRoot", table: "VaultManifests", type: "boolean", nullable: false, defaultValue: false);
migrationBuilder.Sql(@"UPDATE ""VaultManifests"" SET ""StorageFormat"" = 'sqlite-blob', ""IsRoot"" = TRUE;");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" ALTER COLUMN ""IsRoot"" DROP DEFAULT;");
migrationBuilder.Sql(@"
UPDATE ""VaultManifests"" v SET ""ManifestId"" = sub.gid
FROM (SELECT ""OwnerUserId"", gen_random_uuid() AS gid FROM ""VaultManifests"" GROUP BY ""OwnerUserId"") sub
WHERE v.""OwnerUserId"" = sub.""OwnerUserId"";");
migrationBuilder.AlterColumn<string>(
name: "StorageFormat",
table: "VaultManifests",
type: "character varying(20)",
maxLength: 20,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(20)",
oldMaxLength: 20,
oldNullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "ManifestId",
table: "VaultManifests",
type: "uuid",
nullable: false,
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
// --- 3. Manifest history table. ---
migrationBuilder.CreateTable(
name: "VaultManifestsHistory",
columns: table => new
{
RevisionNumber = table.Column<long>(type: "bigint", nullable: false),
ManifestId = table.Column<Guid>(type: "uuid", nullable: false),
OwnerUserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VaultBlob = table.Column<string>(type: "text", nullable: false),
StorageFormat = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ManifestBlob = table.Column<string>(type: "text", nullable: true),
ManifestCiphertextHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
Version = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
FileSize = table.Column<int>(type: "integer", nullable: false),
Salt = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Verifier = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
CredentialsCount = table.Column<int>(type: "integer", nullable: false),
EmailClaimsCount = table.Column<int>(type: "integer", nullable: false),
EncryptionType = table.Column<string>(type: "text", nullable: false),
EncryptionSettings = table.Column<string>(type: "text", nullable: false),
Client = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_VaultManifestsHistory", x => new { x.ManifestId, x.RevisionNumber });
});
// --- 4. Fill manifest history table based on non-current revisions of the old Vaults table. ---
migrationBuilder.Sql(@"
INSERT INTO ""VaultManifestsHistory"" (""ManifestId"", ""RevisionNumber"", ""OwnerUserId"", ""VaultBlob"", ""StorageFormat"", ""ManifestBlob"", ""ManifestCiphertextHash"", ""Version"", ""FileSize"", ""Salt"", ""Verifier"", ""CredentialsCount"", ""EmailClaimsCount"", ""EncryptionType"", ""EncryptionSettings"", ""Client"", ""CreatedAt"", ""UpdatedAt"")
SELECT ""ManifestId"", ""RevisionNumber"", ""OwnerUserId"", ""VaultBlob"", ""StorageFormat"", ""ManifestBlob"", ""ManifestCiphertextHash"", ""Version"", ""FileSize"", ""Salt"", ""Verifier"", ""CredentialsCount"", ""EmailClaimsCount"", ""EncryptionType"", ""EncryptionSettings"", ""Client"", ""CreatedAt"", ""UpdatedAt""
FROM (
SELECT v.*, ROW_NUMBER() OVER (PARTITION BY ""ManifestId"" ORDER BY ""RevisionNumber"" DESC, ""CreatedAt"" DESC, ""Id"" DESC) AS rn
FROM ""VaultManifests"" v
) ranked
WHERE ranked.rn > 1
ON CONFLICT (""ManifestId"", ""RevisionNumber"") DO NOTHING;");
migrationBuilder.Sql(@"
DELETE FROM ""VaultManifests"" v
USING (
SELECT ""Id"", ROW_NUMBER() OVER (PARTITION BY ""ManifestId"" ORDER BY ""RevisionNumber"" DESC, ""CreatedAt"" DESC, ""Id"" DESC) AS rn
FROM ""VaultManifests""
) ranked
WHERE v.""Id"" = ranked.""Id"" AND ranked.rn > 1;");
// --- 5. Swap the per-revision key for the manifest key. ---
migrationBuilder.DropPrimaryKey(name: "PK_VaultManifests", table: "VaultManifests");
migrationBuilder.DropColumn(name: "Id", table: "VaultManifests");
migrationBuilder.AddPrimaryKey(name: "PK_VaultManifests", table: "VaultManifests", column: "ManifestId");
// Every user has exactly one root manifest.
migrationBuilder.CreateIndex(
name: "UX_VaultManifests_OwnerUserId_Root",
table: "VaultManifests",
column: "OwnerUserId",
unique: true,
filter: "\"IsRoot\"");
migrationBuilder.CreateIndex(
name: "IX_VaultManifestsHistory_OwnerUserId",
table: "VaultManifestsHistory",
column: "OwnerUserId");
// Now that the referenced key exists, tie history rows to their manifest.
migrationBuilder.AddForeignKey(
name: "FK_VaultManifestsHistory_VaultManifests_ManifestId",
table: "VaultManifestsHistory",
column: "ManifestId",
principalTable: "VaultManifests",
principalColumn: "ManifestId",
onDelete: ReferentialAction.Cascade);
// --- 6. New tables. ---
migrationBuilder.CreateTable(
name: "VaultBlobObjects",
columns: table => new
{
Hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
OwnerUserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Category = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
EncryptedData = table.Column<byte[]>(type: "bytea", nullable: false),
SizeBytes = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastReferencedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_VaultBlobObjects", x => new { x.Hash, x.OwnerUserId });
table.ForeignKey(
name: "FK_VaultBlobObjects_AliasVaultUsers_OwnerUserId",
column: x => x.OwnerUserId,
principalTable: "AliasVaultUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultDataBuckets",
columns: table => new
{
OwnerUserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Category = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
EncryptedData = table.Column<string>(type: "text", nullable: false),
RevisionNumber = table.Column<long>(type: "bigint", nullable: false),
CiphertextHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_VaultDataBuckets", x => new { x.OwnerUserId, x.Category });
table.ForeignKey(
name: "FK_VaultDataBuckets_AliasVaultUsers_OwnerUserId",
column: x => x.OwnerUserId,
principalTable: "AliasVaultUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultDataBucketsHistory",
columns: table => new
{
RevisionNumber = table.Column<long>(type: "bigint", nullable: false),
OwnerUserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Category = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
EncryptedData = table.Column<string>(type: "text", nullable: false),
CiphertextHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_VaultDataBucketsHistory", x => new { x.OwnerUserId, x.Category, x.RevisionNumber });
table.ForeignKey(
name: "FK_VaultDataBucketsHistory_VaultDataBuckets_OwnerUserId_Catego~",
columns: x => new { x.OwnerUserId, x.Category },
principalTable: "VaultDataBuckets",
principalColumns: new[] { "OwnerUserId", "Category" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultKeys",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
VaultManifestId = table.Column<Guid>(type: "uuid", nullable: true),
KeyType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
WrapScheme = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
WrappedVek = table.Column<string>(type: "text", nullable: false),
Salt = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Verifier = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
EncryptionType = table.Column<string>(type: "text", nullable: true),
EncryptionSettings = table.Column<string>(type: "text", nullable: true),
RecipientPublicKeyId = table.Column<Guid>(type: "uuid", nullable: true),
Metadata = table.Column<string>(type: "jsonb", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_VaultKeys", x => x.Id);
table.ForeignKey(
name: "FK_VaultKeys_AliasVaultUsers_UserId",
column: x => x.UserId,
principalTable: "AliasVaultUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultBlobReferences",
columns: table => new
{
ManifestId = table.Column<Guid>(type: "uuid", nullable: false),
RevisionNumber = table.Column<long>(type: "bigint", nullable: false),
BlobHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_VaultBlobReferences", x => new { x.ManifestId, x.RevisionNumber, x.BlobHash });
table.ForeignKey(
name: "FK_VaultBlobReferences_VaultManifests_ManifestId",
column: x => x.ManifestId,
principalTable: "VaultManifests",
principalColumn: "ManifestId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultBlobObjects_OwnerUserId_Category",
table: "VaultBlobObjects",
columns: new[] { "OwnerUserId", "Category" });
migrationBuilder.CreateIndex(
name: "IX_VaultKeys_VaultManifestId",
table: "VaultKeys",
column: "VaultManifestId");
migrationBuilder.CreateIndex(
name: "UX_VaultKeys_UserId_KeyType_Manifest",
table: "VaultKeys",
columns: new[] { "UserId", "KeyType", "VaultManifestId" },
unique: true);
// --- 7. Email encryption keys become per-manifest, and claims record which key they were issued under. ---
migrationBuilder.DropIndex(
name: "IX_UserEncryptionKeys_UserId",
table: "UserEncryptionKeys");
migrationBuilder.AddColumn<Guid>(
name: "VaultManifestId",
table: "UserEncryptionKeys",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "EncryptionKeyId",
table: "UserEmailClaims",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_UserEncryptionKeys_UserId_VaultManifestId_IsPrimary",
table: "UserEncryptionKeys",
columns: new[] { "UserId", "VaultManifestId", "IsPrimary" });
migrationBuilder.CreateIndex(
name: "IX_UserEncryptionKeys_VaultManifestId",
table: "UserEncryptionKeys",
column: "VaultManifestId");
migrationBuilder.CreateIndex(
name: "IX_UserEmailClaims_EncryptionKeyId",
table: "UserEmailClaims",
column: "EncryptionKeyId");
migrationBuilder.AddForeignKey(
name: "FK_UserEmailClaims_UserEncryptionKeys_EncryptionKeyId",
table: "UserEmailClaims",
column: "EncryptionKeyId",
principalTable: "UserEncryptionKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_UserEncryptionKeys_VaultManifests_VaultManifestId",
table: "UserEncryptionKeys",
column: "VaultManifestId",
principalTable: "VaultManifests",
principalColumn: "ManifestId",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Undo the email encryption key changes.
migrationBuilder.DropForeignKey(name: "FK_UserEmailClaims_UserEncryptionKeys_EncryptionKeyId", table: "UserEmailClaims");
migrationBuilder.DropForeignKey(name: "FK_UserEncryptionKeys_VaultManifests_VaultManifestId", table: "UserEncryptionKeys");
migrationBuilder.DropIndex(name: "IX_UserEncryptionKeys_UserId_VaultManifestId_IsPrimary", table: "UserEncryptionKeys");
migrationBuilder.DropIndex(name: "IX_UserEncryptionKeys_VaultManifestId", table: "UserEncryptionKeys");
migrationBuilder.DropIndex(name: "IX_UserEmailClaims_EncryptionKeyId", table: "UserEmailClaims");
migrationBuilder.DropColumn(name: "VaultManifestId", table: "UserEncryptionKeys");
migrationBuilder.DropColumn(name: "EncryptionKeyId", table: "UserEmailClaims");
migrationBuilder.CreateIndex(name: "IX_UserEncryptionKeys_UserId", table: "UserEncryptionKeys", column: "UserId");
// Drop the tables that had no pre-V2 equivalent (dependents first).
migrationBuilder.DropTable(name: "VaultBlobReferences");
migrationBuilder.DropTable(name: "VaultBlobObjects");
migrationBuilder.DropTable(name: "VaultDataBucketsHistory");
migrationBuilder.DropTable(name: "VaultDataBuckets");
migrationBuilder.DropTable(name: "VaultKeys");
// Re-materialize the revision log: restore the per-revision key, then fold history rows back in.
migrationBuilder.DropForeignKey(name: "FK_VaultManifestsHistory_VaultManifests_ManifestId", table: "VaultManifestsHistory");
migrationBuilder.DropIndex(name: "UX_VaultManifests_OwnerUserId_Root", table: "VaultManifests");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" ADD COLUMN ""Id"" uuid NOT NULL DEFAULT gen_random_uuid();");
migrationBuilder.DropPrimaryKey(name: "PK_VaultManifests", table: "VaultManifests");
migrationBuilder.AddPrimaryKey(name: "PK_VaultManifests", table: "VaultManifests", column: "Id");
migrationBuilder.Sql(@"
INSERT INTO ""VaultManifests"" (""Id"", ""ManifestId"", ""IsRoot"", ""Name"", ""OwnerUserId"", ""VaultBlob"", ""StorageFormat"", ""ManifestBlob"", ""ManifestCiphertextHash"", ""Version"", ""RevisionNumber"", ""FileSize"", ""Salt"", ""Verifier"", ""CredentialsCount"", ""EmailClaimsCount"", ""EncryptionType"", ""EncryptionSettings"", ""Client"", ""CreatedAt"", ""UpdatedAt"")
SELECT gen_random_uuid(), h.""ManifestId"", m.""IsRoot"", m.""Name"", h.""OwnerUserId"", h.""VaultBlob"", h.""StorageFormat"", h.""ManifestBlob"", h.""ManifestCiphertextHash"", h.""Version"", h.""RevisionNumber"", h.""FileSize"", h.""Salt"", h.""Verifier"", h.""CredentialsCount"", h.""EmailClaimsCount"", h.""EncryptionType"", h.""EncryptionSettings"", h.""Client"", h.""CreatedAt"", h.""UpdatedAt""
FROM ""VaultManifestsHistory"" h
INNER JOIN ""VaultManifests"" m ON m.""ManifestId"" = h.""ManifestId"";");
migrationBuilder.DropTable(name: "VaultManifestsHistory");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" ALTER COLUMN ""Id"" DROP DEFAULT;");
migrationBuilder.DropColumn(name: "ManifestId", table: "VaultManifests");
migrationBuilder.DropColumn(name: "IsRoot", table: "VaultManifests");
migrationBuilder.DropColumn(name: "Name", table: "VaultManifests");
migrationBuilder.DropColumn(name: "StorageFormat", table: "VaultManifests");
migrationBuilder.DropColumn(name: "ManifestBlob", table: "VaultManifests");
migrationBuilder.DropColumn(name: "ManifestCiphertextHash", table: "VaultManifests");
// Rename "VaultManifests" back to "Vaults" in place.
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""FK_VaultManifests_AliasVaultUsers_OwnerUserId"" TO ""FK_Vaults_AliasVaultUsers_UserId"";");
migrationBuilder.Sql(@"ALTER TABLE ""VaultManifests"" RENAME CONSTRAINT ""PK_VaultManifests"" TO ""PK_Vaults"";");
migrationBuilder.RenameColumn(name: "OwnerUserId", table: "VaultManifests", newName: "UserId");
migrationBuilder.RenameTable(name: "VaultManifests", newName: "Vaults");
migrationBuilder.CreateIndex(name: "IX_Vaults_UserId", table: "Vaults", column: "UserId");
}
}
}
@@ -743,6 +743,9 @@ namespace AliasServerDb.Migrations
b.Property<bool>("Disabled")
.HasColumnType("boolean");
b.Property<Guid?>("EncryptionKeyId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -755,6 +758,8 @@ namespace AliasServerDb.Migrations
b.HasIndex("Address")
.IsUnique();
b.HasIndex("EncryptionKeyId");
b.HasIndex("UserId", "CreatedAt");
b.HasIndex("UserId", "Disabled");
@@ -787,9 +792,14 @@ namespace AliasServerDb.Migrations
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("VaultManifestId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("VaultManifestId");
b.HasIndex("UserId", "VaultManifestId", "IsPrimary");
b.ToTable("UserEncryptionKeys");
});
@@ -848,12 +858,11 @@ namespace AliasServerDb.Migrations
modelBuilder.Entity("AliasServerDb.VaultDataBucket", b =>
{
b.Property<Guid>("RevisionId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("OwnerUserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -868,25 +877,49 @@ namespace AliasServerDb.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("RevisionNumber")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("RevisionId");
b.HasIndex("OwnerUserId", "Category", "RevisionNumber")
.IsUnique();
b.HasKey("OwnerUserId", "Category");
b.ToTable("VaultDataBuckets");
});
modelBuilder.Entity("AliasServerDb.VaultDataBucketsHistory", b =>
{
b.Property<string>("OwnerUserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Category")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long>("RevisionNumber")
.HasColumnType("bigint");
b.Property<string>("CiphertextHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedData")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("OwnerUserId", "Category", "RevisionNumber");
b.ToTable("VaultDataBucketsHistory");
});
modelBuilder.Entity("AliasServerDb.VaultKey", b =>
{
b.Property<Guid>("Id")
@@ -1323,11 +1356,18 @@ namespace AliasServerDb.Migrations
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey")
.WithMany()
.HasForeignKey("EncryptionKeyId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EmailClaims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("EncryptionKey");
b.Navigation("User");
});
@@ -1339,7 +1379,14 @@ namespace AliasServerDb.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasServerDb.VaultManifest", "VaultManifest")
.WithMany()
.HasForeignKey("VaultManifestId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("User");
b.Navigation("VaultManifest");
});
modelBuilder.Entity("AliasServerDb.VaultBlobObject", b =>
@@ -1375,6 +1422,17 @@ namespace AliasServerDb.Migrations
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.VaultDataBucketsHistory", b =>
{
b.HasOne("AliasServerDb.VaultDataBucket", "Bucket")
.WithMany()
.HasForeignKey("OwnerUserId", "Category")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bucket");
});
modelBuilder.Entity("AliasServerDb.VaultKey", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
@@ -39,6 +39,19 @@ public class UserEmailClaim
[ForeignKey("UserId")]
public virtual AliasVaultUser? User { get; set; }
/// <summary>
/// Gets or sets the encryption key incoming mail for this alias is encrypted with. For most cases this will be null,
/// which means the routing owner's primary personal key is used, resolved at delivery time. For aliases that are shared
/// with other users, this will be set to the encryption key of the shared folder so all members of the folder can decrypt the mail.
/// </summary>
public Guid? EncryptionKeyId { get; set; }
/// <summary>
/// Gets or sets the navigation property to the encryption key mail for this alias is encrypted with.
/// </summary>
[ForeignKey("EncryptionKeyId")]
public virtual UserEncryptionKey? EncryptionKey { get; set; }
/// <summary>
/// Gets or sets the full email address.
/// </summary>
@@ -11,6 +11,10 @@ using System.ComponentModel.DataAnnotations.Schema;
/// <summary>
/// UserEncryptionKey object. This object is used for storing user public keys for encryption.
/// <para>
/// A row can be either personal (VaultManifestId is null) or folder-scoped (VaultManifestId is set).
/// Personal private keys live in the user's EncryptionKeys data bucket, folder-scoped private keys live in the shared folder's manifest.
/// </para>
/// </summary>
public class UserEncryptionKey
{
@@ -32,6 +36,17 @@ public class UserEncryptionKey
[ForeignKey("UserId")]
public virtual AliasVaultUser User { get; set; } = null!;
/// <summary>
/// Gets or sets the shared-folder manifest this key belongs to, or null when it is the user's own personal key.
/// </summary>
public Guid? VaultManifestId { get; set; }
/// <summary>
/// Gets or sets the navigation property to the shared-folder manifest, when this key is folder-scoped.
/// </summary>
[ForeignKey("VaultManifestId")]
public virtual VaultManifest? VaultManifest { get; set; }
/// <summary>
/// Gets or sets the public key.
/// </summary>
@@ -40,6 +55,7 @@ public class UserEncryptionKey
/// <summary>
/// Gets or sets a value indicating whether this public key is the primary key to use by default.
/// Primary is scoped to VaultManifestId: a user has one primary personal key plus one primary key per shared folder they participate in.
/// </summary>
public bool IsPrimary { get; set; }
@@ -6,27 +6,18 @@
//-----------------------------------------------------------------------
namespace AliasServerDb;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using AliasVault.Shared.Models.WebApi.V2.Vault;
/// <summary>
/// A small, independently-syncable user-scoped data bucket. Each bucket holds one kind of data
/// that we deliberately keep OUT of the main vault content manifest so it can sync separately and faster.
/// The current revision of a small, independently-syncable user-scoped data bucket. Each bucket holds one kind of
/// data that we deliberately keep out of the main vault content manifest so it can sync separately and faster.
/// </summary>
public class VaultDataBucket
public class VaultDataBucket : VaultDataBucketBase
{
/// <summary>
/// Gets or sets the per-revision primary key. Each row is one revision of the (OwnerUserId, Category) bucket;
/// the highest <see cref="RevisionNumber"/> for a given (OwnerUserId, Category) is the current one.
/// Gets or sets the user ID foreign key. Part of the composite primary key (OwnerUserId, Category).
/// </summary>
[Key]
public Guid RevisionId { get; set; }
/// <summary>
/// Gets or sets the user ID foreign key.
/// </summary>
[StringLength(255)]
public string OwnerUserId { get; set; } = null!;
/// <summary>
@@ -36,33 +27,7 @@ public class VaultDataBucket
public virtual AliasVaultUser User { get; set; } = null!;
/// <summary>
/// Gets or sets the bucket category/kind (e.g. Settings).
/// Gets or sets the bucket category/kind (e.g. Settings). Part of the composite primary key.
/// </summary>
public required VaultDataBucketCategory Category { get; set; }
/// <summary>
/// Gets or sets the encrypted bucket payload (AES-GCM ciphertext, base64-encoded).
/// </summary>
public required string EncryptedData { get; set; }
/// <summary>
/// Gets or sets the revision number of this bucket.
/// </summary>
public required long RevisionNumber { get; set; }
/// <summary>
/// Gets or sets the SHA-256 (hex) of the encrypted ciphertext for storage-layer integrity check.
/// </summary>
[StringLength(64)]
public string? CiphertextHash { get; set; }
/// <summary>
/// Gets or sets the created timestamp.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the updated timestamp.
/// </summary>
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,56 @@
//-----------------------------------------------------------------------
// <copyright file="VaultDataBucketBase.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Shared revision payload columns for a vault data bucket.
/// <see cref="VaultDataBucket"/> holds the current revision of each (owner, category) bucket.
/// <see cref="VaultDataBucketsHistory"/> holds superseded revisions.
/// </summary>
public abstract class VaultDataBucketBase
{
/// <summary>
/// Gets or sets the encrypted bucket payload (AES-GCM ciphertext, base64-encoded).
/// </summary>
public required string EncryptedData { get; set; }
/// <summary>
/// Gets or sets the revision number of this bucket. Incremented on every write.
/// </summary>
public required long RevisionNumber { get; set; }
/// <summary>
/// Gets or sets the SHA-256 (hex) of the encrypted ciphertext for storage-layer integrity check.
/// </summary>
[StringLength(64)]
public string? CiphertextHash { get; set; }
/// <summary>
/// Gets or sets the created timestamp.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the updated timestamp.
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Copies all shared revision payload columns from another bucket revision onto this instance.
/// </summary>
/// <param name="source">The revision to copy the payload from.</param>
public void CopyPayloadFrom(VaultDataBucketBase source)
{
EncryptedData = source.EncryptedData;
RevisionNumber = source.RevisionNumber;
CiphertextHash = source.CiphertextHash;
CreatedAt = source.CreatedAt;
UpdatedAt = source.UpdatedAt;
}
}
@@ -0,0 +1,53 @@
//-----------------------------------------------------------------------
// <copyright file="VaultDataBucketsHistory.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
using System.ComponentModel.DataAnnotations.Schema;
using AliasVault.Shared.Models.WebApi.V2.Vault;
/// <summary>
/// A superseded revision of a vault data bucket, kept for backup/rollback per the bucket retention policy.
/// On every write the current <see cref="VaultDataBucket"/> row is first copied into this table, after which the current row is updated in place.
/// Composite primary key (OwnerUserId, Category, RevisionNumber).
/// </summary>
public class VaultDataBucketsHistory : VaultDataBucketBase
{
/// <summary>
/// Gets or sets the ID of the owning user. Part of the composite PK.
/// </summary>
public string OwnerUserId { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation property to the current bucket row this revision belongs to.
/// </summary>
[ForeignKey("OwnerUserId, Category")]
public virtual VaultDataBucket Bucket { get; set; } = null!;
/// <summary>
/// Gets or sets the bucket category/kind. Part of the composite PK.
/// </summary>
public required VaultDataBucketCategory Category { get; set; }
/// <summary>
/// Creates a history row from the current revision of a bucket. Called right before the current row is updated
/// in place with a newer revision.
/// </summary>
/// <param name="current">The current bucket row to archive.</param>
/// <returns>A new unsaved history entity carrying the current row's full revision payload.</returns>
public static VaultDataBucketsHistory CreateFrom(VaultDataBucket current)
{
var history = new VaultDataBucketsHistory
{
OwnerUserId = current.OwnerUserId,
Category = current.Category,
EncryptedData = current.EncryptedData,
RevisionNumber = current.RevisionNumber,
};
history.CopyPayloadFrom(current);
return history;
}
}