diff --git a/artifacts/schema/v0.1.sql b/artifacts/schema/v0.1.sql index fbd77a0..35e1337 100644 --- a/artifacts/schema/v0.1.sql +++ b/artifacts/schema/v0.1.sql @@ -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; diff --git a/src/DodoSSH.Api/Authorization/CurrentUserContext.cs b/src/DodoSSH.Api/Authorization/CurrentUserContext.cs new file mode 100644 index 0000000..34c4160 --- /dev/null +++ b/src/DodoSSH.Api/Authorization/CurrentUserContext.cs @@ -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; + +/// Resolves the authenticated caller, provisioning them on first sight. +public interface ICurrentUserContext +{ + /// + /// Returns the caller's account, creating it if this is their first authenticated request. + /// + /// The request is not authenticated. + Task GetOrProvisionAsync(CancellationToken cancellationToken); +} + +/// +/// Request-scoped caller identity with just-in-time provisioning. +/// +/// +/// Identity is keyed on (issuer, subject), 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. +/// +internal sealed class CurrentUserContext( + IHttpContextAccessor accessor, + DodoDbContext database, + IOptions oidcOptions, + TimeProvider clock) + : ICurrentUserContext +{ + private UserAccount? cached; + + /// + public async Task 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 FindAsync(string issuer, string subject, CancellationToken cancellationToken) => + database.Users.SingleOrDefaultAsync( + u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null, + cancellationToken); + + private async Task 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."); +} diff --git a/src/DodoSSH.Api/Authorization/VaultAccessService.cs b/src/DodoSSH.Api/Authorization/VaultAccessService.cs new file mode 100644 index 0000000..9893161 --- /dev/null +++ b/src/DodoSSH.Api/Authorization/VaultAccessService.cs @@ -0,0 +1,82 @@ +using DodoSSH.Domain; +using DodoSSH.Domain.Authorization; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Authorization; + +/// The result of an access check. +/// The vault, when access was granted. +/// The caller's effective permissions. +public readonly record struct VaultAccess(Vault? Vault, PermissionFlags Permissions) +{ + /// Whether the caller may act on the vault at all. + public bool Granted => Vault is not null; + + /// Denied access. + public static VaultAccess Denied => new(null, PermissionFlags.None); +} + +/// Resolves what the caller may do with a vault. +public interface IVaultAccessService +{ + /// + /// Resolves the caller's effective permissions on a vault. + /// + /// + /// Returns 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. + /// + Task ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken); +} + +/// +/// Vault access resolution. +/// +/// +/// +/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the +/// v_user_vault_permission 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. +/// +/// +/// 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. +/// +/// +internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService +{ + /// Everything the owner of a personal vault may do with it. + private const PermissionFlags OwnerPermissions = + PermissionFlags.Read + | PermissionFlags.Write + | PermissionFlags.Connect + | PermissionFlags.Share + | PermissionFlags.Admin; + + /// + public async Task 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; + } +} diff --git a/src/DodoSSH.Api/Features/Sync/CursorKeyProvider.cs b/src/DodoSSH.Api/Features/Sync/CursorKeyProvider.cs new file mode 100644 index 0000000..21846c0 --- /dev/null +++ b/src/DodoSSH.Api/Features/Sync/CursorKeyProvider.cs @@ -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; + +/// Supplies the key that tags sync cursors. +public interface ICursorKeyProvider +{ + /// The signing key. + ReadOnlySpan Key { get; } +} + +/// +/// Resolves the cursor signing key from configuration, generating an ephemeral one if unset. +/// +/// +/// +/// 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. +/// +/// +/// Multi-node deployments must configure it explicitly, or a cursor issued by one node will be +/// rejected by another and clients will resync constantly. +/// +/// +internal sealed class CursorKeyProvider : ICursorKeyProvider +{ + private readonly byte[] key; + + public CursorKeyProvider(IOptions options, ILogger 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."); + } + } + + /// + public ReadOnlySpan Key => key; + + private static bool TryDecodeKey(string configured, out byte[] key) + { + key = []; + + Span 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); +} diff --git a/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs new file mode 100644 index 0000000..878bfc7 --- /dev/null +++ b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs @@ -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; + +/// +/// The vault write path, and the delta read that pairs with it. +/// +/// +/// Push is the only 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. +/// +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, 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, 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(StringComparer.Ordinal) { ["code"] = code }); +} diff --git a/src/DodoSSH.Api/Features/Sync/SyncExceptions.cs b/src/DodoSSH.Api/Features/Sync/SyncExceptions.cs new file mode 100644 index 0000000..7e7654c --- /dev/null +++ b/src/DodoSSH.Api/Features/Sync/SyncExceptions.cs @@ -0,0 +1,11 @@ +namespace DodoSSH.Api.Features.Sync; + +/// A cursor was malformed, mis-tagged, or issued for another vault. +public sealed class InvalidCursorException() : Exception( + "The sync cursor is not valid for this vault. Resync from the beginning."); + +/// A push batch exceeded a configured cap. +public sealed class PushBatchTooLargeException(string message) : Exception(message); + +/// A push batch was structurally unusable as a whole. +public sealed class PushBatchInvalidException(string message) : Exception(message); diff --git a/src/DodoSSH.Api/Features/Sync/SyncLog.cs b/src/DodoSSH.Api/Features/Sync/SyncLog.cs new file mode 100644 index 0000000..cdb3856 --- /dev/null +++ b/src/DodoSSH.Api/Features/Sync/SyncLog.cs @@ -0,0 +1,30 @@ +namespace DodoSSH.Api.Features.Sync; + +/// Source-generated log messages for sync. +/// +/// 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. +/// +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); +} diff --git a/src/DodoSSH.Api/Features/Sync/SyncService.cs b/src/DodoSSH.Api/Features/Sync/SyncService.cs new file mode 100644 index 0000000..8f33a92 --- /dev/null +++ b/src/DodoSSH.Api/Features/Sync/SyncService.cs @@ -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; + +/// Applies and reads a vault's change log. +internal sealed class SyncService( + DodoDbContext database, + IOptions syncOptions, + ICursorKeyProvider cursorKeys, + TimeProvider clock, + ILogger logger) +{ + private readonly SyncOptions options = syncOptions.Value; + + /// Reads changes after a cursor. + internal async Task 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); + } + + /// + /// Applies a batch of operations. + /// + /// + /// + /// The whole batch runs in one transaction whose first statement takes a per-vault + /// advisory lock. That is load-bearing: bigserial 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. + /// + /// + /// Individual operations that conflict are skipped rather than aborting the batch, so one stale + /// item cannot block everything else a client queued while offline. + /// + /// + internal async Task 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(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}."); + } + } + + /// + /// Serialises writers to one vault for the life of the transaction. + /// + /// + /// hashtextextended 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 Multiplexing=false on the connection, + /// which is the Npgsql default — enabling it would break the lock's session affinity. + /// + private Task AcquireVaultLockAsync(Guid vaultId, CancellationToken cancellationToken) => + database.Database.ExecuteSqlAsync( + $"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", + cancellationToken); + + private async Task 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 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); + } + + /// Creates a new item, or rejects an upsert that cannot become one. + private async Task 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 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; + } + + /// + /// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a + /// constraint violation surfacing as a 500. + /// + 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 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 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); + } + + /// Attaches current row state to change-log entries. + private async Task> HydrateAsync( + Vault vault, + List 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(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 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; +} diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index 8c627c8..f877ce9 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddProblemDetails(); + var app = builder.Build(); // Deliberately no UseHttpsRedirection: the API is always fronted by a reverse proxy diff --git a/src/DodoSSH.Api/Setup/Auth.cs b/src/DodoSSH.Api/Setup/Auth.cs index 93ef70c..1c63bb5 100644 --- a/src/DodoSSH.Api/Setup/Auth.cs +++ b/src/DodoSSH.Api/Setup/Auth.cs @@ -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 => diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs index 0b1bd84..3d2fa4e 100644 --- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -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; diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs index 4512ad1..4e7a444 100644 --- a/src/DodoSSH.Domain/Hosts.cs +++ b/src/DodoSSH.Domain/Hosts.cs @@ -17,7 +17,7 @@ namespace DodoSSH.Domain; /// constraint enforces the pairing so it cannot drift. See ADR 0004. /// /// -public sealed class Host +public sealed class SshHost { /// Primary key. UUIDv7, generated by the client so items can be created offline. public Guid Id { get; set; } diff --git a/src/DodoSSH.Domain/Sync/SyncCursor.cs b/src/DodoSSH.Domain/Sync/SyncCursor.cs new file mode 100644 index 0000000..154e92a --- /dev/null +++ b/src/DodoSSH.Domain/Sync/SyncCursor.cs @@ -0,0 +1,193 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace DodoSSH.Domain.Sync; + +/// +/// An opaque, integrity-tagged position in a vault's change log. +/// +/// +/// +/// Clients must never construct or modify one. The tag exists so a tampered cursor is +/// rejected 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. +/// +/// +/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be +/// replayed against another. See ADR 0003. +/// +/// +public static class SyncCursor +{ + private const string Version = "v1"; + private const char Separator = '|'; + + /// Length of the truncated HMAC tag. 128 bits is ample for a non-secret position. + private const int TagLength = 16; + + /// Minimum signing key length. + public const int MinimumKeyLength = 32; + + /// + /// Encodes a position. + /// + /// Deployment cursor signing key, at least 32 bytes. + /// Vault the cursor belongs to. + /// Last change sequence the client has consumed. + public static string Encode(ReadOnlySpan 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); + } + + /// + /// Decodes and verifies a cursor. + /// + /// + /// 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. + /// + /// Deployment cursor signing key. + /// The cursor to verify. + /// Vault the request is scoped to. + /// The decoded sequence, when verification succeeds. + public static bool TryDecode( + ReadOnlySpan 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 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 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 signingKey, + ReadOnlySpan payload, + Span destination) + { + Span full = stackalloc byte[HMACSHA256.HashSizeInBytes]; + HMACSHA256.HashData(signingKey, payload, full); + full[..TagLength].CopyTo(destination); + } + + private static void RequireKey(ReadOnlySpan signingKey) + { + if (signingKey.Length < MinimumKeyLength) + { + throw new ArgumentException( + $"Cursor signing key must be at least {MinimumKeyLength} bytes.", + nameof(signingKey)); + } + } +} + +/// Base64url without padding, as used in URLs and headers. +internal static class Base64Url +{ + internal static string Encode(ReadOnlySpan 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; + } + } +} diff --git a/src/DodoSSH.Domain/SyncLog.cs b/src/DodoSSH.Domain/SyncLog.cs index eabe5f9..10c08a7 100644 --- a/src/DodoSSH.Domain/SyncLog.cs +++ b/src/DodoSSH.Domain/SyncLog.cs @@ -20,7 +20,7 @@ public enum ChangeEntityType Unspecified = 0, /// An SSH host. - Host = 1, + SshHost = 1, /// A credential. M2. Credential = 2, @@ -65,7 +65,7 @@ public enum ChangeEntityType /// statement, so sequence order equals commit order. See ADR 0003. /// /// -public sealed class SyncChange +public sealed class VaultChange { /// Monotonic sequence. Database-generated. public long Sequence { get; set; } diff --git a/src/DodoSSH.Domain/Vaults.cs b/src/DodoSSH.Domain/Vaults.cs index 2765ac8..fe670b2 100644 --- a/src/DodoSSH.Domain/Vaults.cs +++ b/src/DodoSSH.Domain/Vaults.cs @@ -56,7 +56,7 @@ public sealed class Vault public ICollection KeyGrants { get; } = []; /// Hosts in this vault. - public ICollection Hosts { get; } = []; + public ICollection Hosts { get; } = []; } /// diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs index 09fa072..cc7c282 100644 --- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs +++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs @@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace DodoSSH.Infrastructure.Configurations; -/// Maps . -public sealed class HostConfiguration : IEntityTypeConfiguration +/// Maps . +public sealed class HostConfiguration : IEntityTypeConfiguration { /// - public void Configure(EntityTypeBuilder builder) + public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -49,11 +49,11 @@ public sealed class HostConfiguration : IEntityTypeConfiguration } } -/// Maps . -public sealed class SyncChangeConfiguration : IEntityTypeConfiguration +/// Maps . +public sealed class SyncChangeConfiguration : IEntityTypeConfiguration { /// - public void Configure(EntityTypeBuilder builder) + public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs index 41ed19e..19ffa21 100644 --- a/src/DodoSSH.Infrastructure/DodoDbContext.cs +++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs @@ -52,10 +52,10 @@ public class DodoDbContext(DbContextOptions options) : DbContext( public DbSet VaultKeyGrants => Set(); /// SSH hosts. - public DbSet Hosts => Set(); + public DbSet Hosts => Set(); /// The per-vault change log that delta sync reads. - public DbSet SyncChanges => Set(); + public DbSet VaultChanges => Set(); /// Applied-operation receipts, for exactly-once retries. public DbSet SyncOperationReceipts => Set(); diff --git a/src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.Designer.cs similarity index 99% rename from src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.Designer.cs rename to src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.Designer.cs index 1065711..393fdcb 100644 --- a/src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.Designer.cs +++ b/src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.Designer.cs @@ -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 { /// @@ -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("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("EncryptionPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("encryption_public_key"); + + b.Property("Generation") + .HasColumnType("integer") + .HasColumnName("generation"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("PreviousHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("previous_hash"); + + b.Property("SigningPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("signing_public_key"); + + b.Property("StatementSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("statement_signature"); + + b.Property("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("Id") .HasColumnType("uuid") @@ -176,120 +240,6 @@ namespace DodoSSH.Infrastructure.Migrations }); }); - modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b => - { - b.Property("Sequence") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasColumnName("sequence"); - - NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at_utc"); - - b.Property("EncryptionPublicKey") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("encryption_public_key"); - - b.Property("Generation") - .HasColumnType("integer") - .HasColumnName("generation"); - - b.Property("Hash") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("hash"); - - b.Property("PreviousHash") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("previous_hash"); - - b.Property("SigningPublicKey") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("signing_public_key"); - - b.Property("StatementSignature") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("bytea") - .HasColumnName("statement_signature"); - - b.Property("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("Sequence") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasColumnName("sequence"); - - NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); - - b.Property("ActorUserId") - .HasColumnType("uuid") - .HasColumnName("actor_user_id"); - - b.Property("EntityId") - .HasColumnType("uuid") - .HasColumnName("entity_id"); - - b.Property("EntityType") - .HasColumnType("integer") - .HasColumnName("entity_type"); - - b.Property("OccurredAtUtc") - .HasColumnType("timestamp with time zone") - .HasColumnName("occurred_at_utc"); - - b.Property("Operation") - .HasColumnType("integer") - .HasColumnName("operation"); - - b.Property("Revision") - .HasColumnType("integer") - .HasColumnName("revision"); - - b.Property("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("OperationId") @@ -740,6 +690,56 @@ namespace DodoSSH.Infrastructure.Migrations }); }); + modelBuilder.Entity("DodoSSH.Domain.VaultChange", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("Operation") + .HasColumnType("integer") + .HasColumnName("operation"); + + b.Property("Revision") + .HasColumnType("integer") + .HasColumnName("revision"); + + b.Property("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("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") diff --git a/src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.cs b/src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.cs similarity index 100% rename from src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.cs rename to src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.cs diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs index 9b97254..b57000d 100644 --- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs +++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs @@ -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("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("EncryptionPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("encryption_public_key"); + + b.Property("Generation") + .HasColumnType("integer") + .HasColumnName("generation"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("PreviousHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("previous_hash"); + + b.Property("SigningPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("signing_public_key"); + + b.Property("StatementSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("statement_signature"); + + b.Property("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("Id") .HasColumnType("uuid") @@ -173,120 +237,6 @@ namespace DodoSSH.Infrastructure.Migrations }); }); - modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b => - { - b.Property("Sequence") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasColumnName("sequence"); - - NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at_utc"); - - b.Property("EncryptionPublicKey") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("encryption_public_key"); - - b.Property("Generation") - .HasColumnType("integer") - .HasColumnName("generation"); - - b.Property("Hash") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("hash"); - - b.Property("PreviousHash") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("previous_hash"); - - b.Property("SigningPublicKey") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("bytea") - .HasColumnName("signing_public_key"); - - b.Property("StatementSignature") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("bytea") - .HasColumnName("statement_signature"); - - b.Property("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("Sequence") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasColumnName("sequence"); - - NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); - - b.Property("ActorUserId") - .HasColumnType("uuid") - .HasColumnName("actor_user_id"); - - b.Property("EntityId") - .HasColumnType("uuid") - .HasColumnName("entity_id"); - - b.Property("EntityType") - .HasColumnType("integer") - .HasColumnName("entity_type"); - - b.Property("OccurredAtUtc") - .HasColumnType("timestamp with time zone") - .HasColumnName("occurred_at_utc"); - - b.Property("Operation") - .HasColumnType("integer") - .HasColumnName("operation"); - - b.Property("Revision") - .HasColumnType("integer") - .HasColumnName("revision"); - - b.Property("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("OperationId") @@ -737,6 +687,56 @@ namespace DodoSSH.Infrastructure.Migrations }); }); + modelBuilder.Entity("DodoSSH.Domain.VaultChange", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("Operation") + .HasColumnType("integer") + .HasColumnName("operation"); + + b.Property("Revision") + .HasColumnType("integer") + .HasColumnName("revision"); + + b.Property("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("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") diff --git a/tests/DodoSSH.Domain.Tests/Sync/SyncCursorTests.cs b/tests/DodoSSH.Domain.Tests/Sync/SyncCursorTests.cs new file mode 100644 index 0000000..3231e4c --- /dev/null +++ b/tests/DodoSSH.Domain.Tests/Sync/SyncCursorTests.cs @@ -0,0 +1,155 @@ +using System.Security.Cryptography; +using DodoSSH.Domain.Sync; + +namespace DodoSSH.Domain.Tests.Sync; + +/// +/// Cursor encoding and, more importantly, every way a bad cursor must be rejected. +/// +/// +/// 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. +/// +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(() => SyncCursor.Encode(new byte[16], VaultId, 1)); + Should.Throw(() => + SyncCursor.TryDecode(new byte[31], "whatever", VaultId, out _)); + } + + [Fact] + public void RejectsANegativeSequence() + { + Should.Throw(() => 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(); + } +} diff --git a/tests/DodoSSH.Infrastructure.Tests/AdvisoryLockOrderingTests.cs b/tests/DodoSSH.Infrastructure.Tests/AdvisoryLockOrderingTests.cs new file mode 100644 index 0000000..9db5005 --- /dev/null +++ b/tests/DodoSSH.Infrastructure.Tests/AdvisoryLockOrderingTests.cs @@ -0,0 +1,217 @@ +using DodoSSH.Domain; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DodoSSH.Infrastructure.Tests; + +/// +/// Proves the per-vault advisory lock makes change-log sequence order equal commit order. +/// +/// +/// +/// This is the executable form of ADR 0003's central hazard. bigserial assigns sequence +/// values when the INSERT 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. +/// +/// +/// The first test demonstrates the hazard is real by reproducing it without 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. +/// +/// +[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 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 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())!; + } + + /// Sequences a fresh reader can currently see, i.e. committed ones. + private async Task> 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 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; + } +} diff --git a/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs index 8748cf1..9fbe9d3 100644 --- a/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs +++ b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs @@ -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,