Public Access
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.
76 lines
2.7 KiB
C#
76 lines
2.7 KiB
C#
using DodoSSH.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DodoSSH.Infrastructure;
|
|
|
|
/// <summary>
|
|
/// The application database context.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Everything lives in the <c>dodo</c> schema with snake_case names. Timestamps are
|
|
/// <c>timestamptz</c> and always UTC, so there is no tzdata dependency and no conflict with
|
|
/// <c>InvariantGlobalization</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Two concurrency mechanisms coexist deliberately. <c>Version</c> on item rows is the
|
|
/// client-visible, monotonic value used for <c>expectedVersion</c> conflict detection.
|
|
/// <c>xmin</c> is the server-side optimistic guard and is never exposed, because it is not stable
|
|
/// across <c>VACUUM FREEZE</c> and must never become a client cursor.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(options)
|
|
{
|
|
/// <summary>The database schema every table lives in.</summary>
|
|
public const string SchemaName = "dodo";
|
|
|
|
/// <summary>User accounts.</summary>
|
|
public DbSet<UserAccount> Users => Set<UserAccount>();
|
|
|
|
/// <summary>Identity key generations.</summary>
|
|
public DbSet<UserKey> UserKeys => Set<UserKey>();
|
|
|
|
/// <summary>Wraps of users' secret bundles.</summary>
|
|
public DbSet<UserKeyWrap> UserKeyWraps => Set<UserKeyWrap>();
|
|
|
|
/// <summary>Enrolled devices.</summary>
|
|
public DbSet<Device> Devices => Set<Device>();
|
|
|
|
/// <summary>The append-only key transparency log.</summary>
|
|
public DbSet<KeyLogEntry> KeyLog => Set<KeyLogEntry>();
|
|
|
|
/// <summary>Teams.</summary>
|
|
public DbSet<Team> Teams => Set<Team>();
|
|
|
|
/// <summary>Team memberships.</summary>
|
|
public DbSet<TeamMembership> TeamMemberships => Set<TeamMembership>();
|
|
|
|
/// <summary>Vaults.</summary>
|
|
public DbSet<Vault> Vaults => Set<Vault>();
|
|
|
|
/// <summary>Wrapped vault keys.</summary>
|
|
public DbSet<VaultKeyGrant> VaultKeyGrants => Set<VaultKeyGrant>();
|
|
|
|
/// <summary>SSH hosts.</summary>
|
|
public DbSet<SshHost> Hosts => Set<SshHost>();
|
|
|
|
/// <summary>The per-vault change log that delta sync reads.</summary>
|
|
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
|
|
|
/// <summary>Applied-operation receipts, for exactly-once retries.</summary>
|
|
public DbSet<SyncOperationReceipt> SyncOperationReceipts => Set<SyncOperationReceipt>();
|
|
|
|
/// <inheritdoc />
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(modelBuilder);
|
|
|
|
modelBuilder.HasDefaultSchema(SchemaName);
|
|
modelBuilder.HasPostgresExtension("citext");
|
|
|
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(DodoDbContext).Assembly);
|
|
|
|
base.OnModelCreating(modelBuilder);
|
|
}
|
|
}
|