Add sync engine: cursors, push/pull, and the advisory-lock ordering proof (M1)

The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE —
so one place enforces revisions, the change log and access control.

The concurrency hazard, now proven rather than asserted:
bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A
can take sequence 5 while B takes 6 and commits first. A reader polling in between sees
only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces
that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it
would pass just as happily if the interleaving never occurred — then shows
pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps.

Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the
rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly
worse than an error a client can resync from. Rejected: tampered tag, tampered payload,
foreign signing key, a legitimately-issued cursor from another vault, truncation, and
hostile input (never throws — cursors come from clients).

Push semantics:
- 200 even on partial failure, with per-operation status, so one stale item cannot block
  everything a client queued while offline.
- Conflict returns the server's current row for client-side three-way merge. The server
  cannot merge ciphertext, so never last-writer-wins.
- opId receipts make retries exactly-once per operation, not per batch — a client retrying
  a partially-overlapping batch after a timeout would otherwise double-apply what landed.
- A tombstone beats a late upsert, and delete clears hostname/port: leaving the address
  would keep the server able to resolve a host the user believes they deleted.
- Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather
  than a constraint violation surfacing as a 500.

Authorization goes through IVaultAccessService, which returns the same answer for "absent"
and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids.
Team vaults are explicitly denied until M3 rather than falling through to a permissive
default. JIT provisioning keys on (issuer, subject), never email, and handles the
concurrent-first-request race via the unique index.

Renamed two domain types: Host -> SshHost, because Host collides with
Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange ->
VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every
use site would have been permanent friction.

Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames
even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type
names. The snapshot was regenerated and the emitted DDL diffed against the previous
artifacts/schema/v0.1.sql to confirm the rename produced no schema change.

Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing
parallelization limits, which is why MA0004 is suppressed in test projects.

Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean.

Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and
real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two
routes. The service-layer authorization and the concurrency property are covered.
This commit is contained in:
2026-07-28 15:02:02 +02:00
parent d3b14e6bc0
commit 3829217e8a
23 changed files with 1931 additions and 290 deletions
@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace DodoSSH.Infrastructure.Migrations
{
[DbContext(typeof(DodoDbContext))]
[Migration("20260728113419_InitialSchema")]
[Migration("20260728125751_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
@@ -74,7 +74,71 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("device", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<byte[]>("EncryptionPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("encryption_public_key");
b.Property<int>("Generation")
.HasColumnType("integer")
.HasColumnName("generation");
b.Property<byte[]>("Hash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<byte[]>("PreviousHash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("previous_hash");
b.Property<byte[]>("SigningPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("signing_public_key");
b.Property<byte[]>("StatementSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("bytea")
.HasColumnName("statement_signature");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Sequence")
.HasName("pk_key_log");
b.HasIndex("Hash")
.IsUnique()
.HasDatabaseName("ix_key_log_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_key_log_user_id");
b.ToTable("key_log", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
@@ -176,120 +240,6 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<byte[]>("EncryptionPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("encryption_public_key");
b.Property<int>("Generation")
.HasColumnType("integer")
.HasColumnName("generation");
b.Property<byte[]>("Hash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<byte[]>("PreviousHash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("previous_hash");
b.Property<byte[]>("SigningPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("signing_public_key");
b.Property<byte[]>("StatementSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("bytea")
.HasColumnName("statement_signature");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Sequence")
.HasName("pk_key_log");
b.HasIndex("Hash")
.IsUnique()
.HasDatabaseName("ix_key_log_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_key_log_user_id");
b.ToTable("key_log", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.SyncChange", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("EntityId")
.HasColumnType("uuid")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("integer")
.HasColumnName("entity_type");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at_utc");
b.Property<int>("Operation")
.HasColumnType("integer")
.HasColumnName("operation");
b.Property<int>("Revision")
.HasColumnType("integer")
.HasColumnName("revision");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.HasKey("Sequence")
.HasName("pk_sync_change");
b.HasIndex("VaultId", "Sequence")
.HasDatabaseName("ix_sync_change_vault_id_sequence");
b.HasIndex("VaultId", "EntityId", "Sequence")
.IsDescending(false, false, true)
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
b.ToTable("sync_change", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
{
b.Property<Guid>("OperationId")
@@ -740,6 +690,56 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
modelBuilder.Entity("DodoSSH.Domain.VaultChange", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("EntityId")
.HasColumnType("uuid")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("integer")
.HasColumnName("entity_type");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at_utc");
b.Property<int>("Operation")
.HasColumnType("integer")
.HasColumnName("operation");
b.Property<int>("Revision")
.HasColumnType("integer")
.HasColumnName("revision");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.HasKey("Sequence")
.HasName("pk_sync_change");
b.HasIndex("VaultId", "Sequence")
.HasDatabaseName("ix_sync_change_vault_id_sequence");
b.HasIndex("VaultId", "EntityId", "Sequence")
.IsDescending(false, false, true)
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
b.ToTable("sync_change", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b =>
{
b.Property<Guid>("Id")
@@ -841,7 +841,7 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("User");
});
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany("Hosts")
@@ -71,7 +71,71 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("device", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<byte[]>("EncryptionPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("encryption_public_key");
b.Property<int>("Generation")
.HasColumnType("integer")
.HasColumnName("generation");
b.Property<byte[]>("Hash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<byte[]>("PreviousHash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("previous_hash");
b.Property<byte[]>("SigningPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("signing_public_key");
b.Property<byte[]>("StatementSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("bytea")
.HasColumnName("statement_signature");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Sequence")
.HasName("pk_key_log");
b.HasIndex("Hash")
.IsUnique()
.HasDatabaseName("ix_key_log_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_key_log_user_id");
b.ToTable("key_log", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
@@ -173,120 +237,6 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<byte[]>("EncryptionPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("encryption_public_key");
b.Property<int>("Generation")
.HasColumnType("integer")
.HasColumnName("generation");
b.Property<byte[]>("Hash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<byte[]>("PreviousHash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("previous_hash");
b.Property<byte[]>("SigningPublicKey")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("bytea")
.HasColumnName("signing_public_key");
b.Property<byte[]>("StatementSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("bytea")
.HasColumnName("statement_signature");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Sequence")
.HasName("pk_key_log");
b.HasIndex("Hash")
.IsUnique()
.HasDatabaseName("ix_key_log_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_key_log_user_id");
b.ToTable("key_log", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.SyncChange", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("EntityId")
.HasColumnType("uuid")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("integer")
.HasColumnName("entity_type");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at_utc");
b.Property<int>("Operation")
.HasColumnType("integer")
.HasColumnName("operation");
b.Property<int>("Revision")
.HasColumnType("integer")
.HasColumnName("revision");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.HasKey("Sequence")
.HasName("pk_sync_change");
b.HasIndex("VaultId", "Sequence")
.HasDatabaseName("ix_sync_change_vault_id_sequence");
b.HasIndex("VaultId", "EntityId", "Sequence")
.IsDescending(false, false, true)
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
b.ToTable("sync_change", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
{
b.Property<Guid>("OperationId")
@@ -737,6 +687,56 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
modelBuilder.Entity("DodoSSH.Domain.VaultChange", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("sequence");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("EntityId")
.HasColumnType("uuid")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("integer")
.HasColumnName("entity_type");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at_utc");
b.Property<int>("Operation")
.HasColumnType("integer")
.HasColumnName("operation");
b.Property<int>("Revision")
.HasColumnType("integer")
.HasColumnName("revision");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.HasKey("Sequence")
.HasName("pk_sync_change");
b.HasIndex("VaultId", "Sequence")
.HasDatabaseName("ix_sync_change_vault_id_sequence");
b.HasIndex("VaultId", "EntityId", "Sequence")
.IsDescending(false, false, true)
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
b.ToTable("sync_change", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b =>
{
b.Property<Guid>("Id")
@@ -838,7 +838,7 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("User");
});
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany("Hosts")