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
@@ -0,0 +1,124 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
namespace DodoSSH.Api.Features.Sync;
/// <summary>
/// The vault write path, and the delta read that pairs with it.
/// </summary>
/// <remarks>
/// Push is the <em>only</em> way vault items change; there are no per-entity POST, PUT or DELETE
/// endpoints. One place therefore enforces revisions, the change log and access control, which
/// halves both the endpoint count and the authorization surface. See ADR 0003.
/// </remarks>
internal static class SyncEndpoints
{
internal static IEndpointRouteBuilder MapSyncEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/vaults/{vaultId:guid}/sync")
.RequireAuthorization(Auth.AuthenticatedPolicy)
.WithTags("Sync");
// POST rather than GET: the filters live in the body, cursors are opaque, and no caching is
// wanted. Non-mutating despite the verb.
group.MapPost("/pull", PullAsync)
.WithName("SyncPull")
.WithSummary("Reads vault changes after a cursor.");
group.MapPost("/push", PushAsync)
.WithName("SyncPush")
.WithSummary("Applies a batch of vault changes.");
return app;
}
private static async Task<Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>> PullAsync(
Guid vaultId,
SyncPullRequest request,
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
SyncService sync,
CancellationToken cancellationToken)
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
.ConfigureAwait(false);
// 404 rather than 403, and identically for "absent" and "forbidden": distinguishing them is
// an existence oracle for other tenants' vault ids.
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
{
return TypedResults.NotFound();
}
try
{
var response = await sync.PullAsync(access.Vault!, request, cancellationToken)
.ConfigureAwait(false);
return TypedResults.Ok(response);
}
catch (InvalidCursorException exception)
{
return Problem(StatusCodes.Status400BadRequest, ProblemCodes.InvalidCursor, exception.Message);
}
}
private static async Task<Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>> PushAsync(
Guid vaultId,
SyncPushRequest request,
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
SyncService sync,
CancellationToken cancellationToken)
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
.ConfigureAwait(false);
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
{
return TypedResults.NotFound();
}
// Read but not Write: the vault exists and is visible, so 403 leaks nothing here.
if (!access.Permissions.HasFlag(PermissionFlags.Write))
{
return Problem(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"You do not have permission to modify this vault.");
}
try
{
// 200 even when individual operations failed. Per-operation status is in the body, so a
// single stale item cannot block everything else a client queued while offline.
var response = await sync.PushAsync(access.Vault!, user.Id, request, cancellationToken)
.ConfigureAwait(false);
return TypedResults.Ok(response);
}
catch (PushBatchTooLargeException exception)
{
return Problem(
StatusCodes.Status413PayloadTooLarge,
ProblemCodes.PushBatchTooLarge,
exception.Message);
}
catch (PushBatchInvalidException exception)
{
return Problem(StatusCodes.Status400BadRequest, ProblemCodes.PushBatchTooLarge, exception.Message);
}
}
private static ProblemHttpResult Problem(int statusCode, string code, string detail) =>
TypedResults.Problem(
detail: detail,
statusCode: statusCode,
type: ProblemCodes.TypeBaseUri + code,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal) { ["code"] = code });
}