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,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.");
}
@@ -0,0 +1,82 @@
using DodoSSH.Domain;
using DodoSSH.Domain.Authorization;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Authorization;
/// <summary>The result of an access check.</summary>
/// <param name="Vault">The vault, when access was granted.</param>
/// <param name="Permissions">The caller's effective permissions.</param>
public readonly record struct VaultAccess(Vault? Vault, PermissionFlags Permissions)
{
/// <summary>Whether the caller may act on the vault at all.</summary>
public bool Granted => Vault is not null;
/// <summary>Denied access.</summary>
public static VaultAccess Denied => new(null, PermissionFlags.None);
}
/// <summary>Resolves what the caller may do with a vault.</summary>
public interface IVaultAccessService
{
/// <summary>
/// Resolves the caller's effective permissions on a vault.
/// </summary>
/// <remarks>
/// Returns <see cref="VaultAccess.Denied"/> both when the vault does not exist and when the
/// caller cannot see it. Deliberately indistinguishable: a distinct "exists but forbidden"
/// answer is an existence oracle that lets a caller enumerate other tenants' vault ids.
/// </remarks>
Task<VaultAccess> ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken);
}
/// <summary>
/// Vault access resolution.
/// </summary>
/// <remarks>
/// <para>
/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the
/// <c>v_user_vault_permission</c> view and per-resource ACLs arrive in M3 — this is the one place
/// that changes, which is why every caller goes through it rather than comparing owner ids inline.
/// </para>
/// <para>
/// A team vault is explicitly denied for now rather than falling through to a permissive default.
/// Failing closed on an unimplemented path is the only safe direction.
/// </para>
/// </remarks>
internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
{
/// <summary>Everything the owner of a personal vault may do with it.</summary>
private const PermissionFlags OwnerPermissions =
PermissionFlags.Read
| PermissionFlags.Write
| PermissionFlags.Connect
| PermissionFlags.Share
| PermissionFlags.Admin;
/// <inheritdoc />
public async Task<VaultAccess> ResolveAsync(
Guid userId,
Guid vaultId,
CancellationToken cancellationToken)
{
var vault = await database.Vaults
.SingleOrDefaultAsync(v => v.Id == vaultId && v.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (vault is null)
{
return VaultAccess.Denied;
}
if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId)
{
return new VaultAccess(vault, OwnerPermissions);
}
// Team vaults are not readable until M3 wires up membership and grants. Denying is the
// correct behaviour in the meantime.
return VaultAccess.Denied;
}
}
@@ -0,0 +1,85 @@
using System.Security.Cryptography;
using DodoSSH.Api.Setup;
using DodoSSH.Domain.Sync;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Sync;
/// <summary>Supplies the key that tags sync cursors.</summary>
public interface ICursorKeyProvider
{
/// <summary>The signing key.</summary>
ReadOnlySpan<byte> Key { get; }
}
/// <summary>
/// Resolves the cursor signing key from configuration, generating an ephemeral one if unset.
/// </summary>
/// <remarks>
/// <para>
/// Losing this key is harmless: it only invalidates in-flight cursors, and a client that gets a
/// rejected cursor resyncs from the beginning. That is why an unconfigured deployment gets a random
/// per-process key rather than a startup failure — it works, and the only cost is that cursors do
/// not survive a restart or span multiple nodes.
/// </para>
/// <para>
/// Multi-node deployments must configure it explicitly, or a cursor issued by one node will be
/// rejected by another and clients will resync constantly.
/// </para>
/// </remarks>
internal sealed class CursorKeyProvider : ICursorKeyProvider
{
private readonly byte[] key;
public CursorKeyProvider(IOptions<SyncOptions> options, ILogger<CursorKeyProvider> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
var configured = options.Value.CursorSigningKey;
if (string.IsNullOrWhiteSpace(configured))
{
key = RandomNumberGenerator.GetBytes(SyncCursor.MinimumKeyLength);
CursorKeyLog.EphemeralKeyGenerated(logger);
return;
}
if (!TryDecodeKey(configured, out key))
{
throw new InvalidOperationException(
"Sync:CursorSigningKey must be base64 and decode to at least "
+ $"{SyncCursor.MinimumKeyLength} bytes.");
}
}
/// <inheritdoc />
public ReadOnlySpan<byte> Key => key;
private static bool TryDecodeKey(string configured, out byte[] key)
{
key = [];
Span<byte> buffer = new byte[configured.Length];
if (!Convert.TryFromBase64String(configured, buffer, out var written)
|| written < SyncCursor.MinimumKeyLength)
{
return false;
}
key = buffer[..written].ToArray();
return true;
}
}
internal static partial class CursorKeyLog
{
[LoggerMessage(
EventId = 2010,
Level = LogLevel.Warning,
Message = "Sync:CursorSigningKey is not configured, so an ephemeral per-process key was "
+ "generated. Cursors will not survive a restart and will be rejected across nodes, "
+ "causing clients to resync from the beginning. Configure it for any multi-node or "
+ "production deployment.")]
internal static partial void EphemeralKeyGenerated(ILogger logger);
}
@@ -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 });
}
@@ -0,0 +1,11 @@
namespace DodoSSH.Api.Features.Sync;
/// <summary>A cursor was malformed, mis-tagged, or issued for another vault.</summary>
public sealed class InvalidCursorException() : Exception(
"The sync cursor is not valid for this vault. Resync from the beginning.");
/// <summary>A push batch exceeded a configured cap.</summary>
public sealed class PushBatchTooLargeException(string message) : Exception(message);
/// <summary>A push batch was structurally unusable as a whole.</summary>
public sealed class PushBatchInvalidException(string message) : Exception(message);
+30
View File
@@ -0,0 +1,30 @@
namespace DodoSSH.Api.Features.Sync;
/// <summary>Source-generated log messages for sync.</summary>
/// <remarks>
/// Deliberately records ids, versions and counts only. Payloads are ciphertext, but their sizes and
/// access patterns still leak, and there is no diagnostic value in them.
/// </remarks>
internal static partial class SyncLog
{
[LoggerMessage(
EventId = 2001,
Level = LogLevel.Information,
Message = "Push conflict on item {EntityId}: client expected version {ExpectedVersion}, "
+ "server holds {ServerVersion}.")]
internal static partial void PushConflict(
ILogger logger,
Guid entityId,
int? expectedVersion,
int? serverVersion);
[LoggerMessage(
EventId = 2002,
Level = LogLevel.Information,
Message = "Applied {AppliedCount} of {OperationCount} operation(s) to vault {VaultId}.")]
internal static partial void PushApplied(
ILogger logger,
int appliedCount,
int operationCount,
Guid vaultId);
}
@@ -0,0 +1,602 @@
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Domain.Sync;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace DodoSSH.Api.Features.Sync;
/// <summary>Applies and reads a vault's change log.</summary>
internal sealed class SyncService(
DodoDbContext database,
IOptions<SyncOptions> syncOptions,
ICursorKeyProvider cursorKeys,
TimeProvider clock,
ILogger<SyncService> logger)
{
private readonly SyncOptions options = syncOptions.Value;
/// <summary>Reads changes after a cursor.</summary>
internal async Task<SyncPullResponse> PullAsync(
Vault vault,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var afterSequence = 0L;
if (!string.IsNullOrEmpty(request.Cursor)
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence))
{
throw new InvalidCursorException();
}
var limit = Math.Clamp(
request.Limit ?? options.DefaultPullLimit,
1,
options.MaxPullLimit);
var types = request.EntityTypes is { Count: > 0 }
? request.EntityTypes.Select(ToDomain).ToArray()
: null;
// One extra row, so "is there more" needs no second query.
var query = database.VaultChanges
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence);
if (types is not null)
{
query = query.Where(c => types.Contains(c.EntityType));
}
var changes = await query
.OrderBy(c => c.Sequence)
.Take(limit + 1)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var hasMore = changes.Count > limit;
if (hasMore)
{
changes.RemoveAt(changes.Count - 1);
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
}
/// <summary>
/// Applies a batch of operations.
/// </summary>
/// <remarks>
/// <para>
/// The whole batch runs in one transaction whose <em>first</em> statement takes a per-vault
/// advisory lock. That is load-bearing: <c>bigserial</c> hands out sequence values before
/// commit, so without serialising writers per vault, transaction A can take sequence 5 while B
/// takes 6 and commits first. A reader advancing its cursor to 6 then misses 5 permanently.
/// See ADR 0003.
/// </para>
/// <para>
/// Individual operations that conflict are skipped rather than aborting the batch, so one stale
/// item cannot block everything else a client queued while offline.
/// </para>
/// </remarks>
internal async Task<SyncPushResponse> PushAsync(
Vault vault,
Guid actorUserId,
SyncPushRequest request,
CancellationToken cancellationToken)
{
ValidateBatchLimits(request);
var strategy = database.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
await AcquireVaultLockAsync(vault.Id, cancellationToken).ConfigureAwait(false);
var results = new List<SyncPushResult>(request.Operations.Count);
var highestSequence = 0L;
foreach (var operation in request.Operations)
{
var result = await ApplyAsync(vault, actorUserId, operation, cancellationToken)
.ConfigureAwait(false);
results.Add(result);
if (result.ChangeSequence is { } sequence && sequence > highestSequence)
{
highestSequence = sequence;
}
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
// If nothing was applied, report the log head so the client does not rewind.
if (highestSequence == 0)
{
highestSequence = await CurrentHeadAsync(vault.Id, cancellationToken)
.ConfigureAwait(false);
}
return new SyncPushResponse(
Results: results,
Cursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, highestSequence));
}).ConfigureAwait(false);
}
private void ValidateBatchLimits(SyncPushRequest request)
{
if (request.Operations.Count == 0)
{
throw new PushBatchInvalidException("A push must contain at least one operation.");
}
if (request.Operations.Count > options.MaxOperationsPerPush)
{
throw new PushBatchTooLargeException(
$"A push may contain at most {options.MaxOperationsPerPush} operations; "
+ $"{request.Operations.Count} were supplied.");
}
long total = 0;
foreach (var operation in request.Operations)
{
var length = operation.Payload?.Envelope.Length ?? 0;
if (length > options.MaxItemPayloadBytes)
{
throw new PushBatchTooLargeException(
$"Item {operation.EntityId} payload is {length} bytes; the limit is "
+ $"{options.MaxItemPayloadBytes}.");
}
total += length;
}
if (total > options.MaxPayloadBytes)
{
throw new PushBatchTooLargeException(
$"Total push payload is {total} bytes; the limit is {options.MaxPayloadBytes}.");
}
}
/// <summary>
/// Serialises writers to one vault for the life of the transaction.
/// </summary>
/// <remarks>
/// <c>hashtextextended</c> over the vault id gives a stable 64-bit lock key. Contention is
/// per-vault, and a push is a single transaction anyway, so the cost is negligible next to the
/// silent sync corruption it prevents. Requires <c>Multiplexing=false</c> on the connection,
/// which is the Npgsql default — enabling it would break the lock's session affinity.
/// </remarks>
private Task<int> AcquireVaultLockAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Database.ExecuteSqlAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))",
cancellationToken);
private async Task<SyncPushResult> ApplyAsync(
Vault vault,
Guid actorUserId,
SyncPushOperation operation,
CancellationToken cancellationToken)
{
if (operation.EntityType != SyncEntityType.Host)
{
// M1 syncs hosts only. Other types are reserved in the contract so a newer client
// gets a precise per-operation answer rather than a whole-batch failure.
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
}
if (operation.Operation is not (SyncOperation.Upsert or SyncOperation.Delete))
{
return Invalid(operation, "Operation must be Upsert or Delete.");
}
// Exactly-once at operation granularity. A batch-level idempotency key alone would
// double-apply the operations that did land when a client retries a partially-overlapping
// batch after a timeout.
var receipt = await database.SyncOperationReceipts
.SingleOrDefaultAsync(r => r.OperationId == operation.OperationId, cancellationToken)
.ConfigureAwait(false);
if (receipt is not null)
{
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Duplicate,
receipt.ResultVersion,
receipt.AppliedChangeSequence,
ServerEntity: null,
Detail: null);
}
var host = await database.Hosts
.SingleOrDefaultAsync(h => h.Id == operation.EntityId, cancellationToken)
.ConfigureAwait(false);
// An id that exists in another vault must not be addressable from this one, and must not
// reveal that it exists elsewhere.
if (host is not null && host.VaultId != vault.Id)
{
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Forbidden,
Version: null,
ChangeSequence: null,
ServerEntity: null,
Detail: null);
}
return operation.Operation == SyncOperation.Delete
? await ApplyDeleteAsync(vault, actorUserId, operation, host, cancellationToken)
.ConfigureAwait(false)
: await ApplyUpsertAsync(vault, actorUserId, operation, host, cancellationToken)
.ConfigureAwait(false);
}
private async Task<SyncPushResult> ApplyUpsertAsync(
Vault vault,
Guid actorUserId,
SyncPushOperation operation,
SshHost? existing,
CancellationToken cancellationToken)
{
if (operation.Payload is null)
{
return Invalid(operation, "An upsert requires a payload.");
}
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
if (!ValidateRelayFields(fields, out var relayError))
{
return Invalid(operation, relayError);
}
var now = clock.GetUtcNow();
if (existing is null || existing.DeletedAtUtc is not null)
{
return await CreateAsync(
vault, actorUserId, operation, existing, fields, now, cancellationToken)
.ConfigureAwait(false);
}
if (operation.ExpectedVersion != existing.Version)
{
return await ConflictAsync(vault, operation, existing, cancellationToken)
.ConfigureAwait(false);
}
existing.Version++;
ApplyFields(existing, operation.Payload, fields, actorUserId, now);
return await RecordAsync(
vault, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Creates a new item, or rejects an upsert that cannot become one.</summary>
private async Task<SyncPushResult> CreateAsync(
Vault vault,
Guid actorUserId,
SyncPushOperation operation,
SshHost? existing,
SyncPlaintextFields fields,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// A tombstone wins over a late upsert. The client is told, so it can resurrect the item
// deliberately under a new id rather than silently undoing someone else's delete.
if (existing?.DeletedAtUtc is not null)
{
return await ConflictAsync(vault, operation, existing, cancellationToken)
.ConfigureAwait(false);
}
if (operation.ExpectedVersion is not null)
{
// The client believes it is updating something that does not exist here.
return await ConflictAsync(vault, operation, existing: null, cancellationToken)
.ConfigureAwait(false);
}
var created = new SshHost
{
Id = operation.EntityId,
VaultId = vault.Id,
Version = 1,
CreatedAtUtc = now,
CreatedByUserId = actorUserId,
};
ApplyFields(created, operation.Payload!, fields, actorUserId, now);
database.Hosts.Add(created);
return await RecordAsync(
vault, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken)
.ConfigureAwait(false);
}
private async Task<SyncPushResult> ApplyDeleteAsync(
Vault vault,
Guid actorUserId,
SyncPushOperation operation,
SshHost? existing,
CancellationToken cancellationToken)
{
if (existing is null)
{
return Invalid(operation, "Cannot delete an item that does not exist.");
}
if (existing.DeletedAtUtc is not null)
{
// Already a tombstone. Idempotent rather than an error: a client retrying a delete it
// is unsure about should not have to distinguish these.
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
existing.Version,
existing.ChangeSequence,
ServerEntity: null,
Detail: null);
}
if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
{
return await ConflictAsync(vault, operation, existing, cancellationToken)
.ConfigureAwait(false);
}
var now = clock.GetUtcNow();
existing.Version++;
existing.DeletedAtUtc = now;
existing.UpdatedAtUtc = now;
existing.UpdatedByUserId = actorUserId;
// The address must go with the item. Leaving it would keep the server able to resolve a
// host the user believes they deleted.
existing.RelayEnabled = false;
existing.Hostname = null;
existing.Port = null;
return await RecordAsync(
vault, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken)
.ConfigureAwait(false);
}
private static void ApplyFields(
SshHost host,
EncryptedPayload payload,
SyncPlaintextFields fields,
Guid actorUserId,
DateTimeOffset now)
{
host.Payload = payload.Envelope;
host.KeyGeneration = (int)payload.KeyGeneration;
host.PayloadAadVersion = payload.AadVersion;
host.RelayEnabled = fields.RelayEnabled;
host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
host.Port = fields.RelayEnabled ? fields.Port : null;
host.GroupId = fields.GroupId;
host.DeletedAtUtc = null;
host.UpdatedAtUtc = now;
host.UpdatedByUserId = actorUserId;
}
/// <summary>
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a
/// constraint violation surfacing as a 500.
/// </summary>
private static bool ValidateRelayFields(SyncPlaintextFields fields, out string error)
{
error = string.Empty;
if (fields.RelayEnabled)
{
if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
{
error = "Relay-enabled hosts require both a hostname and a port.";
return false;
}
if (fields.Port is < 1 or > 65535)
{
error = "Port must be between 1 and 65535.";
return false;
}
return true;
}
if (fields.Hostname is not null || fields.Port is not null)
{
error = "A hostname or port may only be supplied when relay is enabled for the host.";
return false;
}
return true;
}
private async Task<SyncPushResult> RecordAsync(
Vault vault,
Guid actorUserId,
SyncPushOperation operation,
SshHost host,
ChangeOperation changeOperation,
CancellationToken cancellationToken)
{
var change = new VaultChange
{
VaultId = vault.Id,
EntityType = ChangeEntityType.SshHost,
EntityId = host.Id,
Operation = changeOperation,
Revision = host.Version,
ActorUserId = actorUserId,
OccurredAtUtc = clock.GetUtcNow(),
};
database.VaultChanges.Add(change);
// Saved before the denormalised pointer is set, because the sequence is assigned by the
// database on insert.
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
host.ChangeSequence = change.Sequence;
database.SyncOperationReceipts.Add(new SyncOperationReceipt
{
OperationId = operation.OperationId,
VaultId = vault.Id,
AppliedChangeSequence = change.Sequence,
ResultVersion = host.Version,
CreatedAtUtc = clock.GetUtcNow(),
});
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
host.Version,
change.Sequence,
ServerEntity: null,
Detail: null);
}
private async Task<SyncPushResult> ConflictAsync(
Vault vault,
SyncPushOperation operation,
SshHost? existing,
CancellationToken cancellationToken)
{
// The server cannot merge ciphertext, so it hands back its current state and the client
// performs a three-way merge against its retained common ancestor. Never last-writer-wins.
Contracts.SyncChange? serverEntity = null;
if (existing is not null)
{
var hydrated = await HydrateAsync(
vault,
[new VaultChange
{
Sequence = existing.ChangeSequence,
VaultId = existing.VaultId,
EntityType = ChangeEntityType.SshHost,
EntityId = existing.Id,
Operation = existing.DeletedAtUtc is null
? ChangeOperation.Upsert
: ChangeOperation.Delete,
Revision = existing.Version,
OccurredAtUtc = existing.UpdatedAtUtc,
}],
cancellationToken).ConfigureAwait(false);
serverEntity = hydrated.Count > 0 ? hydrated[0] : null;
}
SyncLog.PushConflict(logger, operation.EntityId, operation.ExpectedVersion, existing?.Version);
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Conflict,
existing?.Version,
existing?.ChangeSequence,
serverEntity,
Detail: null);
}
/// <summary>Attaches current row state to change-log entries.</summary>
private async Task<List<Contracts.SyncChange>> HydrateAsync(
Vault vault,
List<VaultChange> changes,
CancellationToken cancellationToken)
{
var hostIds = changes
.Where(c => c.EntityType == ChangeEntityType.SshHost)
.Select(c => c.EntityId)
.Distinct()
.ToArray();
var hosts = hostIds.Length == 0
? []
: await database.Hosts
.Where(h => h.VaultId == vault.Id && hostIds.Contains(h.Id))
.ToDictionaryAsync(h => h.Id, cancellationToken)
.ConfigureAwait(false);
var result = new List<Contracts.SyncChange>(changes.Count);
foreach (var change in changes)
{
hosts.TryGetValue(change.EntityId, out var host);
// A delete carries no payload: there is nothing left to decrypt, and shipping the
// pre-delete ciphertext would undermine the point of the tombstone.
var isDelete = change.Operation == ChangeOperation.Delete
|| host?.DeletedAtUtc is not null;
result.Add(new Contracts.SyncChange(
EntityType: SyncEntityType.Host,
EntityId: change.EntityId,
Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert,
Version: change.Revision,
ChangeSequence: change.Sequence,
Payload: isDelete || host is null
? null
: new EncryptedPayload(
host.Payload,
(uint)host.KeyGeneration,
(byte)host.PayloadAadVersion),
PlaintextFields: isDelete || host is null
? null
: new SyncPlaintextFields(
RelayEnabled: host.RelayEnabled,
Hostname: host.Hostname,
Port: host.Port,
GroupId: host.GroupId),
UpdatedAt: change.OccurredAtUtc));
}
return result;
}
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
await database.VaultChanges
.Where(c => c.VaultId == vaultId)
.OrderByDescending(c => c.Sequence)
.Select(c => c.Sequence)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new(
operation.OperationId,
SyncOperationStatus.Invalid,
Version: null,
ChangeSequence: null,
ServerEntity: null,
Detail: detail);
private static ChangeEntityType ToDomain(SyncEntityType type) => (ChangeEntityType)(int)type;
}
+9
View File
@@ -1,3 +1,5 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Setup;
var builder = WebApplication.CreateBuilder(args);
@@ -16,6 +18,13 @@ builder.Services.AddDodoHealthChecks();
// TimeProvider so time can be faked in tests.
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<IVaultAccessService, VaultAccessService>();
builder.Services.AddScoped<SyncService>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Deliberately no UseHttpsRedirection: the API is always fronted by a reverse proxy
+5
View File
@@ -47,6 +47,11 @@ internal static class Auth
// Tokens are the one thing that must never reach a log or a trace.
options.IncludeErrorDetails = false;
// Keep claim names as the provider issued them. The default mapping rewrites
// "sub" to a long WS-Federation URI, which makes provider-agnostic claim
// configuration confusing and silently breaks when a provider is swapped.
options.MapInboundClaims = false;
});
services.AddAuthorization(options =>
@@ -1,4 +1,5 @@
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync;
namespace DodoSSH.Api.Setup;
@@ -16,12 +17,12 @@ internal static class EndpointRegistration
internal static WebApplication MapDodoEndpoints(this WebApplication app)
{
app.MapMetaEndpoints();
app.MapSyncEndpoints();
// Registered as each feature lands:
// Identity — /me, enrollment, key rotation, devices
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
// Sync — pull and push
// Relay — tickets and the WebSocket
// Teams, Audit, Admin
return app;