Public Access
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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user