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:
+40
-40
@@ -14,7 +14,7 @@ START TRANSACTION;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'dodo') THEN
|
||||
CREATE SCHEMA dodo;
|
||||
END IF;
|
||||
@@ -23,7 +23,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'dodo') THEN
|
||||
CREATE SCHEMA dodo;
|
||||
END IF;
|
||||
@@ -32,14 +32,14 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.key_log (
|
||||
sequence bigint GENERATED ALWAYS AS IDENTITY,
|
||||
user_id uuid NOT NULL,
|
||||
@@ -57,7 +57,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.sync_change (
|
||||
sequence bigint GENERATED ALWAYS AS IDENTITY,
|
||||
vault_id uuid NOT NULL,
|
||||
@@ -74,7 +74,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.sync_operation_receipt (
|
||||
operation_id uuid NOT NULL,
|
||||
vault_id uuid NOT NULL,
|
||||
@@ -88,7 +88,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.team (
|
||||
id uuid NOT NULL,
|
||||
name character varying(256) NOT NULL,
|
||||
@@ -104,7 +104,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.user_account (
|
||||
id uuid NOT NULL,
|
||||
issuer character varying(512) NOT NULL,
|
||||
@@ -124,7 +124,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.device (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
@@ -142,7 +142,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.team_membership (
|
||||
id uuid NOT NULL,
|
||||
team_id uuid NOT NULL,
|
||||
@@ -162,7 +162,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.user_key (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
@@ -184,7 +184,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.vault (
|
||||
id uuid NOT NULL,
|
||||
name character varying(256) NOT NULL,
|
||||
@@ -209,7 +209,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.user_key_wrap (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
@@ -238,7 +238,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.host (
|
||||
id uuid NOT NULL,
|
||||
vault_id uuid NOT NULL,
|
||||
@@ -270,7 +270,7 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE TABLE dodo.vault_key_grant (
|
||||
id uuid NOT NULL,
|
||||
vault_id uuid NOT NULL,
|
||||
@@ -296,170 +296,170 @@ END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_device_user_id ON dodo.device (user_id);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_host_vault_id_change_sequence ON dodo.host (vault_id, change_sequence);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_host_vault_live ON dodo.host (vault_id) WHERE deleted_at_utc IS NULL;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_key_log_hash ON dodo.key_log (hash);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_key_log_user_id ON dodo.key_log (user_id);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_sync_change_vault_id_entity_id_sequence ON dodo.sync_change (vault_id, entity_id, sequence DESC);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_sync_change_vault_id_sequence ON dodo.sync_change (vault_id, sequence);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_sync_operation_receipt_vault_id_created_at_utc ON dodo.sync_operation_receipt (vault_id, created_at_utc);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_team_slug ON dodo.team (slug) WHERE deleted_at_utc IS NULL;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_team_membership_team_id_user_id ON dodo.team_membership (team_id, user_id) WHERE deleted_at_utc IS NULL;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_team_membership_user_id ON dodo.team_membership (user_id);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_user_account_email ON dodo.user_account (email) WHERE email IS NOT NULL AND deleted_at_utc IS NULL;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_user_account_issuer_subject ON dodo.user_account (issuer, subject);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_user_key_current ON dodo.user_key (user_id) WHERE is_current;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_user_key_fingerprint_sha256 ON dodo.user_key (fingerprint_sha256);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_user_key_user_id_generation ON dodo.user_key (user_id, generation);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_user_key_wrap_device_id ON dodo.user_key_wrap (device_id);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_user_key_wrap_user_device ON dodo.user_key_wrap (user_id, device_id) WHERE device_id IS NOT NULL;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_user_key_wrap_user_kind ON dodo.user_key_wrap (user_id, kind) WHERE device_id IS NULL;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_vault_owner_user_id ON dodo.vault (owner_user_id);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_vault_team_id ON dodo.vault (team_id);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE INDEX ix_vault_key_grant_recipient_user_id ON dodo.vault_key_grant (recipient_user_id);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
CREATE UNIQUE INDEX ix_vault_key_grant_vault_id_key_generation_recipient_user_id ON dodo.vault_key_grant (vault_id, key_generation, recipient_user_id) WHERE revoked_at_utc IS NULL AND recipient_user_id IS NOT NULL;
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
|
||||
INSERT INTO dodo."__EFMigrationsHistory" (migration_id, product_version)
|
||||
VALUES ('20260728113419_InitialSchema', '10.0.10');
|
||||
VALUES ('20260728125751_InitialSchema', '10.0.10');
|
||||
END IF;
|
||||
END $EF$;
|
||||
COMMIT;
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace DodoSSH.Domain;
|
||||
/// constraint enforces the pairing so it cannot drift. See ADR 0004.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Host
|
||||
public sealed class SshHost
|
||||
{
|
||||
/// <summary>Primary key. UUIDv7, generated by the client so items can be created offline.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Domain.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// An opaque, integrity-tagged position in a vault's change log.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Clients must never construct or modify one. The tag exists so a tampered cursor is
|
||||
/// <em>rejected</em> rather than silently mis-serving: an untagged cursor would let a caller
|
||||
/// rewind to sequence 0 and re-read everything, or skip forward and permanently miss changes
|
||||
/// while believing it was up to date. Silent data loss is far worse than an error.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
|
||||
/// replayed against another. See ADR 0003.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SyncCursor
|
||||
{
|
||||
private const string Version = "v1";
|
||||
private const char Separator = '|';
|
||||
|
||||
/// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary>
|
||||
private const int TagLength = 16;
|
||||
|
||||
/// <summary>Minimum signing key length.</summary>
|
||||
public const int MinimumKeyLength = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a position.
|
||||
/// </summary>
|
||||
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
||||
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
||||
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
||||
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence)
|
||||
{
|
||||
RequireKey(signingKey);
|
||||
|
||||
if (sequence < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence));
|
||||
|
||||
var buffer = new byte[payload.Length + TagLength];
|
||||
payload.CopyTo(buffer, 0);
|
||||
ComputeTag(signingKey, payload, buffer.AsSpan(payload.Length, TagLength));
|
||||
|
||||
return Base64Url.Encode(buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes and verifies a cursor.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns false for anything malformed, mis-tagged, or issued for a different vault. It never
|
||||
/// throws on bad input: cursors arrive from clients, so rejection is an expected outcome.
|
||||
/// </remarks>
|
||||
/// <param name="signingKey">Deployment cursor signing key.</param>
|
||||
/// <param name="cursor">The cursor to verify.</param>
|
||||
/// <param name="expectedVaultId">Vault the request is scoped to.</param>
|
||||
/// <param name="sequence">The decoded sequence, when verification succeeds.</param>
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> signingKey,
|
||||
string? cursor,
|
||||
Guid expectedVaultId,
|
||||
out long sequence)
|
||||
{
|
||||
RequireKey(signingKey);
|
||||
sequence = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Base64Url.TryDecode(cursor, out var buffer) || buffer.Length <= TagLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var payload = buffer.AsSpan(0, buffer.Length - TagLength);
|
||||
var providedTag = buffer.AsSpan(buffer.Length - TagLength, TagLength);
|
||||
|
||||
Span<byte> expectedTag = stackalloc byte[TagLength];
|
||||
ComputeTag(signingKey, payload, expectedTag);
|
||||
|
||||
// Constant-time: a timing oracle here would let an attacker forge a tag byte by byte.
|
||||
if (!CryptographicOperations.FixedTimeEquals(providedTag, expectedTag))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryParsePayload(payload, expectedVaultId, out sequence);
|
||||
}
|
||||
|
||||
private static string FormatPayload(Guid vaultId, long sequence) =>
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}");
|
||||
|
||||
private static bool TryParsePayload(ReadOnlySpan<byte> payload, Guid expectedVaultId, out long sequence)
|
||||
{
|
||||
sequence = 0;
|
||||
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = Encoding.UTF8.GetString(payload);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tagged, so the shape is ours — but parse defensively anyway, because a key rotation
|
||||
// could make an old format verify against a new expectation.
|
||||
var parts = text.Split(Separator);
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(parts[0], Version, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Guid.TryParseExact(parts[1], "D", out var vaultId) || vaultId != expectedVaultId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)
|
||||
&& sequence >= 0;
|
||||
}
|
||||
|
||||
private static void ComputeTag(
|
||||
ReadOnlySpan<byte> signingKey,
|
||||
ReadOnlySpan<byte> payload,
|
||||
Span<byte> destination)
|
||||
{
|
||||
Span<byte> full = stackalloc byte[HMACSHA256.HashSizeInBytes];
|
||||
HMACSHA256.HashData(signingKey, payload, full);
|
||||
full[..TagLength].CopyTo(destination);
|
||||
}
|
||||
|
||||
private static void RequireKey(ReadOnlySpan<byte> signingKey)
|
||||
{
|
||||
if (signingKey.Length < MinimumKeyLength)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Cursor signing key must be at least {MinimumKeyLength} bytes.",
|
||||
nameof(signingKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Base64url without padding, as used in URLs and headers.</summary>
|
||||
internal static class Base64Url
|
||||
{
|
||||
internal static string Encode(ReadOnlySpan<byte> value) =>
|
||||
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
internal static bool TryDecode(string value, out byte[] result)
|
||||
{
|
||||
result = [];
|
||||
|
||||
var normalised = value.Replace('-', '+').Replace('_', '/');
|
||||
var padding = (4 - (normalised.Length % 4)) % 4;
|
||||
if (padding == 3)
|
||||
{
|
||||
// A length of 4n+1 cannot be valid base64.
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
result = Convert.FromBase64String(normalised + new string('=', padding));
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public enum ChangeEntityType
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>An SSH host.</summary>
|
||||
Host = 1,
|
||||
SshHost = 1,
|
||||
|
||||
/// <summary>A credential. M2.</summary>
|
||||
Credential = 2,
|
||||
@@ -65,7 +65,7 @@ public enum ChangeEntityType
|
||||
/// statement, so sequence order equals commit order. See ADR 0003.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SyncChange
|
||||
public sealed class VaultChange
|
||||
{
|
||||
/// <summary>Monotonic sequence. Database-generated.</summary>
|
||||
public long Sequence { get; set; }
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class Vault
|
||||
public ICollection<VaultKeyGrant> KeyGrants { get; } = [];
|
||||
|
||||
/// <summary>Hosts in this vault.</summary>
|
||||
public ICollection<Host> Hosts { get; } = [];
|
||||
public ICollection<SshHost> Hosts { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DodoSSH.Infrastructure.Configurations;
|
||||
|
||||
/// <summary>Maps <see cref="Host"/>.</summary>
|
||||
public sealed class HostConfiguration : IEntityTypeConfiguration<Host>
|
||||
/// <summary>Maps <see cref="SshHost"/>.</summary>
|
||||
public sealed class HostConfiguration : IEntityTypeConfiguration<SshHost>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<Host> builder)
|
||||
public void Configure(EntityTypeBuilder<SshHost> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
@@ -49,11 +49,11 @@ public sealed class HostConfiguration : IEntityTypeConfiguration<Host>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps <see cref="SyncChange"/>.</summary>
|
||||
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<SyncChange>
|
||||
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
||||
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<SyncChange> builder)
|
||||
public void Configure(EntityTypeBuilder<VaultChange> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
||||
public DbSet<VaultKeyGrant> VaultKeyGrants => Set<VaultKeyGrant>();
|
||||
|
||||
/// <summary>SSH hosts.</summary>
|
||||
public DbSet<Host> Hosts => Set<Host>();
|
||||
public DbSet<SshHost> Hosts => Set<SshHost>();
|
||||
|
||||
/// <summary>The per-vault change log that delta sync reads.</summary>
|
||||
public DbSet<SyncChange> SyncChanges => Set<SyncChange>();
|
||||
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
||||
|
||||
/// <summary>Applied-operation receipts, for exactly-once retries.</summary>
|
||||
public DbSet<SyncOperationReceipt> SyncOperationReceipts => Set<SyncOperationReceipt>();
|
||||
|
||||
+117
-117
@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace DodoSSH.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DodoDbContext))]
|
||||
[Migration("20260728113419_InitialSchema")]
|
||||
[Migration("20260728125751_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -74,7 +74,71 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.ToTable("device", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
|
||||
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
b.Property<byte[]>("EncryptionPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("encryption_public_key");
|
||||
|
||||
b.Property<int>("Generation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("generation");
|
||||
|
||||
b.Property<byte[]>("Hash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<byte[]>("PreviousHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("previous_hash");
|
||||
|
||||
b.Property<byte[]>("SigningPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("signing_public_key");
|
||||
|
||||
b.Property<byte[]>("StatementSignature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("statement_signature");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_key_log");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_key_log_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_key_log_user_id");
|
||||
|
||||
b.ToTable("key_log", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
@@ -176,120 +240,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
b.Property<byte[]>("EncryptionPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("encryption_public_key");
|
||||
|
||||
b.Property<int>("Generation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("generation");
|
||||
|
||||
b.Property<byte[]>("Hash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<byte[]>("PreviousHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("previous_hash");
|
||||
|
||||
b.Property<byte[]>("SigningPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("signing_public_key");
|
||||
|
||||
b.Property<byte[]>("StatementSignature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("statement_signature");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_key_log");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_key_log_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_key_log_user_id");
|
||||
|
||||
b.ToTable("key_log", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SyncChange", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<Guid>("ActorUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("actor_user_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_at_utc");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_sync_change");
|
||||
|
||||
b.HasIndex("VaultId", "Sequence")
|
||||
.HasDatabaseName("ix_sync_change_vault_id_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityId", "Sequence")
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
|
||||
|
||||
b.ToTable("sync_change", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
|
||||
{
|
||||
b.Property<Guid>("OperationId")
|
||||
@@ -740,6 +690,56 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultChange", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<Guid>("ActorUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("actor_user_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_at_utc");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_sync_change");
|
||||
|
||||
b.HasIndex("VaultId", "Sequence")
|
||||
.HasDatabaseName("ix_sync_change_vault_id_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityId", "Sequence")
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
|
||||
|
||||
b.ToTable("sync_change", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -841,7 +841,7 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
|
||||
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
|
||||
{
|
||||
b.HasOne("DodoSSH.Domain.Vault", "Vault")
|
||||
.WithMany("Hosts")
|
||||
@@ -71,7 +71,71 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.ToTable("device", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
|
||||
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
b.Property<byte[]>("EncryptionPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("encryption_public_key");
|
||||
|
||||
b.Property<int>("Generation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("generation");
|
||||
|
||||
b.Property<byte[]>("Hash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<byte[]>("PreviousHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("previous_hash");
|
||||
|
||||
b.Property<byte[]>("SigningPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("signing_public_key");
|
||||
|
||||
b.Property<byte[]>("StatementSignature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("statement_signature");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_key_log");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_key_log_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_key_log_user_id");
|
||||
|
||||
b.ToTable("key_log", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
@@ -173,120 +237,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
b.Property<byte[]>("EncryptionPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("encryption_public_key");
|
||||
|
||||
b.Property<int>("Generation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("generation");
|
||||
|
||||
b.Property<byte[]>("Hash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<byte[]>("PreviousHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("previous_hash");
|
||||
|
||||
b.Property<byte[]>("SigningPublicKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("signing_public_key");
|
||||
|
||||
b.Property<byte[]>("StatementSignature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("statement_signature");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_key_log");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_key_log_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_key_log_user_id");
|
||||
|
||||
b.ToTable("key_log", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SyncChange", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<Guid>("ActorUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("actor_user_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_at_utc");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_sync_change");
|
||||
|
||||
b.HasIndex("VaultId", "Sequence")
|
||||
.HasDatabaseName("ix_sync_change_vault_id_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityId", "Sequence")
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
|
||||
|
||||
b.ToTable("sync_change", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
|
||||
{
|
||||
b.Property<Guid>("OperationId")
|
||||
@@ -737,6 +687,56 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultChange", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Sequence"));
|
||||
|
||||
b.Property<Guid>("ActorUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("actor_user_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_at_utc");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_sync_change");
|
||||
|
||||
b.HasIndex("VaultId", "Sequence")
|
||||
.HasDatabaseName("ix_sync_change_vault_id_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityId", "Sequence")
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
|
||||
|
||||
b.ToTable("sync_change", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -838,7 +838,7 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.Host", b =>
|
||||
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
|
||||
{
|
||||
b.HasOne("DodoSSH.Domain.Vault", "Vault")
|
||||
.WithMany("Hosts")
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Domain.Sync;
|
||||
|
||||
namespace DodoSSH.Domain.Tests.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Cursor encoding and, more importantly, every way a bad cursor must be rejected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The negative cases are the point. An accepted-but-wrong cursor causes silent data loss — the
|
||||
/// client believes it is up to date while having skipped changes — which is strictly worse than an
|
||||
/// error the client can retry from scratch.
|
||||
/// </remarks>
|
||||
public sealed class SyncCursorTests
|
||||
{
|
||||
private static readonly byte[] Key = RandomNumberGenerator.GetBytes(32);
|
||||
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid OtherVaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L)]
|
||||
[InlineData(1L)]
|
||||
[InlineData(42L)]
|
||||
[InlineData(long.MaxValue)]
|
||||
public void RoundTrips(long sequence)
|
||||
{
|
||||
var cursor = SyncCursor.Encode(Key, VaultId, sequence);
|
||||
|
||||
SyncCursor.TryDecode(Key, cursor, VaultId, out var decoded).ShouldBeTrue();
|
||||
decoded.ShouldBe(sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDeterministic()
|
||||
{
|
||||
SyncCursor.Encode(Key, VaultId, 7).ShouldBe(SyncCursor.Encode(Key, VaultId, 7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsUrlSafeAndUnpadded()
|
||||
{
|
||||
var cursor = SyncCursor.Encode(Key, VaultId, 12345);
|
||||
|
||||
cursor.ShouldNotContain("+");
|
||||
cursor.ShouldNotContain("/");
|
||||
cursor.ShouldNotContain("=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoesNotRevealTheSequenceInPlainSight()
|
||||
{
|
||||
// Not a security property — the position is not secret — but it discourages clients from
|
||||
// parsing or synthesising cursors, which is what the opacity is actually for.
|
||||
SyncCursor.Encode(Key, VaultId, 987654).ShouldNotContain("987654");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsATamperedTag()
|
||||
{
|
||||
var cursor = SyncCursor.Encode(Key, VaultId, 100);
|
||||
var tampered = cursor[..^1] + (cursor[^1] == 'A' ? 'B' : 'A');
|
||||
|
||||
SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsATamperedPayload()
|
||||
{
|
||||
// The attack this prevents: rewriting the sequence to skip ahead, so the client never
|
||||
// learns about the changes in between.
|
||||
var cursor = SyncCursor.Encode(Key, VaultId, 100);
|
||||
var mutated = cursor.ToCharArray();
|
||||
mutated[0] = mutated[0] == 'x' ? 'y' : 'x';
|
||||
|
||||
SyncCursor.TryDecode(Key, new string(mutated), VaultId, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsACursorSignedWithAnotherKey()
|
||||
{
|
||||
var foreign = SyncCursor.Encode(RandomNumberGenerator.GetBytes(32), VaultId, 100);
|
||||
|
||||
SyncCursor.TryDecode(Key, foreign, VaultId, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsACursorIssuedForAnotherVault()
|
||||
{
|
||||
// Legitimately issued and correctly tagged, but for a different vault. Without the vault
|
||||
// id inside the payload this would decode to a sequence from an unrelated log and serve
|
||||
// the wrong slice of history.
|
||||
var cursor = SyncCursor.Encode(Key, OtherVaultId, 100);
|
||||
|
||||
SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not-base64!!")]
|
||||
[InlineData("AAAA")]
|
||||
[InlineData("A")]
|
||||
public void RejectsMalformedInput(string? cursor)
|
||||
{
|
||||
SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsATruncatedCursor()
|
||||
{
|
||||
var cursor = SyncCursor.Encode(Key, VaultId, 100);
|
||||
|
||||
SyncCursor.TryDecode(Key, cursor[..(cursor.Length / 2)], VaultId, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverThrowsOnClientSuppliedInput()
|
||||
{
|
||||
// Cursors come from clients, so rejection must be a return value rather than an exception
|
||||
// that becomes a 500.
|
||||
string[] hostile =
|
||||
[
|
||||
"\0", "…", new string('A', 10_000), "____", "----", "v1|x|y", "%%%",
|
||||
];
|
||||
|
||||
foreach (var value in hostile)
|
||||
{
|
||||
Should.NotThrow(() => SyncCursor.TryDecode(Key, value, VaultId, out _));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAnUndersizedSigningKey()
|
||||
{
|
||||
// Misconfiguration must fail loudly at the call site rather than producing weak tags.
|
||||
Should.Throw<ArgumentException>(() => SyncCursor.Encode(new byte[16], VaultId, 1));
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
SyncCursor.TryDecode(new byte[31], "whatever", VaultId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsANegativeSequence()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => SyncCursor.Encode(Key, VaultId, -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentSequencesProduceDifferentCursors()
|
||||
{
|
||||
var first = SyncCursor.Encode(Key, VaultId, 1);
|
||||
var second = SyncCursor.Encode(Key, VaultId, 2);
|
||||
|
||||
string.Equals(first, second, StringComparison.Ordinal).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using DodoSSH.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Infrastructure.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the per-vault advisory lock makes change-log sequence order equal commit order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the executable form of ADR 0003's central hazard. <c>bigserial</c> assigns sequence
|
||||
/// values when the <c>INSERT</c> runs, not when the transaction commits. So without serialising
|
||||
/// writers per vault, transaction A can take sequence 5 while B takes 6, and B can commit first.
|
||||
/// A reader that polls in between sees only 6, advances its cursor past 5, and never learns about
|
||||
/// it — silent, permanent data loss for that item.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The first test demonstrates the hazard is real by reproducing it <em>without</em> the lock. The
|
||||
/// second shows the lock removes it. Without the first, the second proves nothing: it would pass
|
||||
/// just as happily if the interleaving never occurred.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(PostgresCollection.Name)]
|
||||
public sealed class AdvisoryLockOrderingTests(PostgresFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 28, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutTheLock_ACommittedGapIsObservable()
|
||||
{
|
||||
// Deliberately reproduces the bug. If this ever stops failing to produce a gap, either
|
||||
// PostgreSQL's sequence behaviour changed or the test stopped interleaving — and the
|
||||
// guarantee the next test claims would no longer be meaningful.
|
||||
var vaultId = await SeedVaultAsync();
|
||||
|
||||
await using var first = new NpgsqlConnection(fixture.ConnectionString);
|
||||
await using var second = new NpgsqlConnection(fixture.ConnectionString);
|
||||
await first.OpenAsync();
|
||||
await second.OpenAsync();
|
||||
|
||||
await using var firstTransaction = await first.BeginTransactionAsync();
|
||||
await using var secondTransaction = await second.BeginTransactionAsync();
|
||||
|
||||
// A takes the lower sequence...
|
||||
var lowSequence = await InsertChangeAsync(first, firstTransaction, vaultId);
|
||||
|
||||
// ...B takes the higher one, and commits first.
|
||||
var highSequence = await InsertChangeAsync(second, secondTransaction, vaultId);
|
||||
await secondTransaction.CommitAsync();
|
||||
|
||||
highSequence.ShouldBeGreaterThan(lowSequence);
|
||||
|
||||
// A reader now sees the high sequence but not the low one. Advancing a cursor to
|
||||
// highSequence would skip lowSequence forever once A commits.
|
||||
var visible = await VisibleSequencesAsync(vaultId);
|
||||
visible.ShouldContain(highSequence);
|
||||
visible.ShouldNotContain(lowSequence);
|
||||
|
||||
await firstTransaction.CommitAsync();
|
||||
|
||||
// And here is the damage: the skipped row is now visible, but behind the cursor.
|
||||
var afterBothCommitted = await VisibleSequencesAsync(vaultId);
|
||||
afterBothCommitted.ShouldContain(lowSequence);
|
||||
afterBothCommitted.ShouldContain(highSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithTheLock_SequenceOrderMatchesCommitOrder()
|
||||
{
|
||||
var vaultId = await SeedVaultAsync();
|
||||
|
||||
await using var first = new NpgsqlConnection(fixture.ConnectionString);
|
||||
await using var second = new NpgsqlConnection(fixture.ConnectionString);
|
||||
await first.OpenAsync();
|
||||
await second.OpenAsync();
|
||||
|
||||
await using var firstTransaction = await first.BeginTransactionAsync();
|
||||
await AcquireVaultLockAsync(first, firstTransaction, vaultId);
|
||||
var lowSequence = await InsertChangeAsync(first, firstTransaction, vaultId);
|
||||
|
||||
// The second writer blocks on the lock rather than racing ahead to a higher sequence.
|
||||
await using var secondTransaction = await second.BeginTransactionAsync();
|
||||
var blocked = Task.Run(async () =>
|
||||
{
|
||||
await AcquireVaultLockAsync(second, secondTransaction, vaultId);
|
||||
var sequence = await InsertChangeAsync(second, secondTransaction, vaultId)
|
||||
;
|
||||
await secondTransaction.CommitAsync();
|
||||
return sequence;
|
||||
});
|
||||
|
||||
// Give it a moment to prove it really is waiting, not merely slow.
|
||||
var completedEarly = await Task.WhenAny(blocked, Task.Delay(TimeSpan.FromMilliseconds(750)))
|
||||
;
|
||||
completedEarly.ShouldNotBe(blocked, "the second writer should be blocked on the advisory lock");
|
||||
|
||||
await firstTransaction.CommitAsync();
|
||||
|
||||
var highSequence = await blocked;
|
||||
|
||||
// The writer that committed first holds the lower sequence, so a reader advancing its
|
||||
// cursor monotonically can never skip a committed row.
|
||||
highSequence.ShouldBeGreaterThan(lowSequence);
|
||||
|
||||
var visible = await VisibleSequencesAsync(vaultId);
|
||||
visible.ShouldContain(lowSequence);
|
||||
visible.ShouldContain(highSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithTheLock_ConcurrentWritersProduceNoGaps()
|
||||
{
|
||||
// The property that actually matters, under real contention: reading the log in sequence
|
||||
// order after every writer has committed must yield a contiguous, complete set.
|
||||
var vaultId = await SeedVaultAsync();
|
||||
const int Writers = 12;
|
||||
|
||||
var tasks = Enumerable.Range(0, Writers).Select(async _ =>
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
await AcquireVaultLockAsync(connection, transaction, vaultId);
|
||||
var sequence = await InsertChangeAsync(connection, transaction, vaultId);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return sequence;
|
||||
});
|
||||
|
||||
var sequences = await Task.WhenAll(tasks);
|
||||
|
||||
sequences.Distinct().Count().ShouldBe(Writers);
|
||||
|
||||
var visible = await VisibleSequencesAsync(vaultId);
|
||||
visible.Count.ShouldBe(Writers);
|
||||
visible.OrderBy(s => s).ShouldBe(sequences.OrderBy(s => s));
|
||||
}
|
||||
|
||||
private static Task<int> AcquireVaultLockAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
Guid vaultId)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = "SELECT pg_advisory_xact_lock(hashtextextended(@vault::text, 0))";
|
||||
command.Parameters.AddWithValue("vault", vaultId);
|
||||
return command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task<long> InsertChangeAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
Guid vaultId)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
INSERT INTO dodo.sync_change
|
||||
(vault_id, entity_type, entity_id, operation, revision, actor_user_id, occurred_at_utc)
|
||||
VALUES (@vault, 1, @entity, 1, 1, @actor, now())
|
||||
RETURNING sequence
|
||||
""";
|
||||
command.Parameters.AddWithValue("vault", vaultId);
|
||||
command.Parameters.AddWithValue("entity", Guid.CreateVersion7());
|
||||
command.Parameters.AddWithValue("actor", Guid.CreateVersion7());
|
||||
|
||||
return (long)(await command.ExecuteScalarAsync())!;
|
||||
}
|
||||
|
||||
/// <summary>Sequences a fresh reader can currently see, i.e. committed ones.</summary>
|
||||
private async Task<List<long>> VisibleSequencesAsync(Guid vaultId)
|
||||
{
|
||||
await using var context = fixture.CreateContext();
|
||||
|
||||
return await context.VaultChanges
|
||||
.Where(c => c.VaultId == vaultId)
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => c.Sequence)
|
||||
.ToListAsync()
|
||||
;
|
||||
}
|
||||
|
||||
private async Task<Guid> SeedVaultAsync()
|
||||
{
|
||||
await using var context = fixture.CreateContext();
|
||||
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Issuer = "https://idp.example",
|
||||
Subject = Guid.CreateVersion7().ToString(),
|
||||
Status = UserStatus.Active,
|
||||
CreatedAtUtc = Now,
|
||||
UpdatedAtUtc = Now,
|
||||
};
|
||||
|
||||
var vault = new Vault
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Name = "Personal",
|
||||
OwnerKind = VaultOwnerKind.Personal,
|
||||
OwnerUserId = user.Id,
|
||||
KeyGeneration = 1,
|
||||
CreatedAtUtc = Now,
|
||||
UpdatedAtUtc = Now,
|
||||
};
|
||||
|
||||
context.Users.Add(user);
|
||||
context.Vaults.Add(vault);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return vault.Id;
|
||||
}
|
||||
}
|
||||
@@ -398,7 +398,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
||||
|
||||
var first = NewChange(vault.Id);
|
||||
var second = NewChange(vault.Id);
|
||||
context.SyncChanges.AddRange(first, second);
|
||||
context.VaultChanges.AddRange(first, second);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
first.Sequence.ShouldBeGreaterThan(0);
|
||||
@@ -511,7 +511,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
||||
return vault;
|
||||
}
|
||||
|
||||
private static Host NewHost(Vault vault, bool relayEnabled, string? hostname, int? port) => new()
|
||||
private static SshHost NewHost(Vault vault, bool relayEnabled, string? hostname, int? port) => new()
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = vault.Id,
|
||||
@@ -558,10 +558,10 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
|
||||
CreatedAtUtc = Now,
|
||||
};
|
||||
|
||||
private static SyncChange NewChange(Guid vaultId) => new()
|
||||
private static VaultChange NewChange(Guid vaultId) => new()
|
||||
{
|
||||
VaultId = vaultId,
|
||||
EntityType = ChangeEntityType.Host,
|
||||
EntityType = ChangeEntityType.SshHost,
|
||||
EntityId = Guid.CreateVersion7(),
|
||||
Operation = ChangeOperation.Upsert,
|
||||
Revision = 1,
|
||||
|
||||
Reference in New Issue
Block a user