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:
jaap-jan committed 2026-07-28 15:02:02 +02:00
1 parent d3b14e6bc0
commit 3829217e8a
23 files changed
+1931 -290

No files matched your search

@@ -0,0 +1,127 @@
using System.Security.Claims;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace DodoSSH.Api.Authorization;
/// <summary>Resolves the authenticated caller, provisioning them on first sight.</summary>
public interface ICurrentUserContext
{
/// <summary>
/// Returns the caller's account, creating it if this is their first authenticated request.
/// </summary>
/// <exception cref="InvalidOperationException">The request is not authenticated.</exception>
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Request-scoped caller identity with just-in-time provisioning.
/// </summary>
/// <remarks>
/// Identity is keyed on <c>(issuer, subject)</c>, never on email. Matching an existing account by
/// email means anyone who can obtain a token bearing a victim's email address — from any configured
/// provider — inherits that victim's vaults, so it is opt-in configuration and off by default.
/// </remarks>
internal sealed class CurrentUserContext(
IHttpContextAccessor accessor,
DodoDbContext database,
IOptions<Setup.OidcOptions> oidcOptions,
TimeProvider clock)
: ICurrentUserContext
{
private UserAccount? cached;
/// <inheritdoc />
public async Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken)
{
if (cached is not null)
{
return cached;
}
var principal = accessor.HttpContext?.User
?? throw new InvalidOperationException("No HTTP context is available.");
if (principal.Identity?.IsAuthenticated != true)
{
throw new InvalidOperationException("The request is not authenticated.");
}
var issuer = RequireClaim(principal, "iss");
var subject = RequireClaim(principal, "sub");
var options = oidcOptions.Value;
var email = principal.FindFirstValue(options.EmailClaim);
var displayName = principal.FindFirstValue(options.NameClaim);
cached = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false)
?? await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
.ConfigureAwait(false);
return cached;
}
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
database.Users.SingleOrDefaultAsync(
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
cancellationToken);
private async Task<UserAccount> ProvisionAsync(
string issuer,
string subject,
string? email,
string? displayName,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var user = new UserAccount
{
Id = Guid.CreateVersion7(),
Issuer = issuer,
Subject = subject,
Email = email,
DisplayName = displayName,
Status = UserStatus.Active,
CreatedAtUtc = now,
UpdatedAtUtc = now,
LastSeenAtUtc = now,
};
database.Users.Add(user);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return user;
}
catch (DbUpdateException exception)
when (string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal))
{
// Two concurrent first requests from the same new user. The unique index on
// (issuer, subject) is what makes this safe: one insert wins and the other reads it
// back, rather than both proceeding with a duplicate account.
database.Entry(user).State = EntityState.Detached;
var winner = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
if (winner is not null)
{
return winner;
}
// The unique violation came from something other than the race we expected — for
// instance the partial unique index on email. Do not swallow it.
throw;
}
}
private static string RequireClaim(ClaimsPrincipal principal, string claimType) =>
principal.FindFirstValue(claimType)
?? throw new InvalidOperationException(
$"The access token is missing the required '{claimType}' claim.");
}