diff --git a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs new file mode 100644 index 0000000..7385339 --- /dev/null +++ b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs @@ -0,0 +1,294 @@ +using DodoSSH.Contracts; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Features.Sync; + +/// +/// Everything about one item type that the shared write path cannot know. +/// +/// +/// +/// The push path is the most dangerous code in the server: it holds the version check, the change-log +/// append, the receipt that makes a retry exactly-once, and the advisory lock that keeps the sequence in +/// commit order. Duplicating that per item type would mean ten copies of it by M5, and a fix applied to nine +/// of them. So it is written once over , and everything genuinely type-specific — +/// which table, which plaintext columns, what those columns must satisfy — arrives through here. +/// +/// +/// Deliberately not generic. A generic IItemKind<TItem> reads better in isolation and would +/// force every method on the write path to be generic too, including the async ones, for no benefit: the +/// path never needs the concrete type, only the shared shape. The downcast each implementation performs is +/// contained to one class per type and is guaranteed by construction, because the same class both creates +/// and queries the rows. +/// +/// +internal interface IItemKind +{ + /// The type as the wire contract names it. + SyncEntityType WireType { get; } + + /// The type as the change log names it. + /// + /// Stated rather than cast. The two enums do agree numerically — and they have different member names + /// for the same value, Host against SshHost — but that alignment is hand-kept and nothing + /// in the type system defends it. Writing it out per kind means adding a type cannot silently file its + /// changes under another type's name. + /// + ChangeEntityType ChangeType { get; } + + /// Finds one item by id, across every vault, so a cross-vault id can be refused. + Task FindAsync(DodoDbContext database, Guid id, CancellationToken cancellationToken); + + /// Loads the rows behind a set of change-log entries, scoped to one vault. + Task> LoadAsync( + DodoDbContext database, + Guid vaultId, + Guid[] ids, + CancellationToken cancellationToken); + + /// Creates an empty row of this type and tracks it. + IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId); + + /// Rejects plaintext fields this type may not carry. + bool ValidateFields(SyncPlaintextFields fields, out string error); + + /// Copies this type's plaintext columns out of the request. + void ApplyFields(IVaultItem item, SyncPlaintextFields fields); + + /// Clears plaintext columns that must not outlive the item. + void ClearFieldsOnDelete(IVaultItem item); + + /// Reads this type's plaintext columns back for a pull, or null when it has none. + SyncPlaintextFields? Hydrate(IVaultItem item); +} + +/// The item types this server can synchronise, by wire type. +internal static class ItemKinds +{ + private static readonly Dictionary Supported = + new[] { (IItemKind)new HostKind(), new SshKeyKind() } + .ToDictionary(kind => kind.WireType); + + /// The kind for a wire type, or null when this server does not synchronise it yet. + /// + /// Null rather than a throw. Every entity type in the contract is reachable by a newer client, and the + /// push endpoint answers per operation — so an unsupported type has to become one Invalid result + /// with a reason, not a failed batch that also rejects the operations this server did understand. + /// + internal static IItemKind? For(SyncEntityType type) => + Supported.TryGetValue(type, out var kind) ? kind : null; +} + +/// Hosts: the one item type with a deliberate plaintext concession. +internal sealed class HostKind : IItemKind +{ + /// + public SyncEntityType WireType => SyncEntityType.Host; + + /// + public ChangeEntityType ChangeType => ChangeEntityType.SshHost; + + /// + public async Task FindAsync( + DodoDbContext database, + Guid id, + CancellationToken cancellationToken) => + await database.Hosts.SingleOrDefaultAsync(h => h.Id == id, cancellationToken) + .ConfigureAwait(false); + + /// + public async Task> LoadAsync( + DodoDbContext database, + Guid vaultId, + Guid[] ids, + CancellationToken cancellationToken) + { + var rows = await database.Hosts + .Where(h => h.VaultId == vaultId && ids.Contains(h.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); + } + + /// + public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) + { + var host = new SshHost { Id = id, VaultId = vaultId }; + + database.Hosts.Add(host); + + return host; + } + + /// + /// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint + /// violation surfacing as a 500. + /// + /// + public bool ValidateFields(SyncPlaintextFields fields, out string error) + { + ArgumentNullException.ThrowIfNull(fields); + + 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; + } + + /// + public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) + { + ArgumentNullException.ThrowIfNull(fields); + + var host = (SshHost)item; + + host.RelayEnabled = fields.RelayEnabled; + host.Hostname = fields.RelayEnabled ? fields.Hostname : null; + host.Port = fields.RelayEnabled ? fields.Port : null; + host.GroupId = fields.GroupId; + } + + /// + /// The address goes with the item. Leaving it would keep the server able to resolve a host the user + /// believes they deleted. + /// + /// + public void ClearFieldsOnDelete(IVaultItem item) + { + var host = (SshHost)item; + + host.RelayEnabled = false; + host.Hostname = null; + host.Port = null; + } + + /// + public SyncPlaintextFields? Hydrate(IVaultItem item) + { + var host = (SshHost)item; + + return new SyncPlaintextFields( + RelayEnabled: host.RelayEnabled, + Hostname: host.Hostname, + Port: host.Port, + GroupId: host.GroupId); + } +} + +/// SSH keys: ciphertext and nothing else. +internal sealed class SshKeyKind : IItemKind +{ + /// + public SyncEntityType WireType => SyncEntityType.SshKey; + + /// + public ChangeEntityType ChangeType => ChangeEntityType.SshKey; + + /// + public async Task FindAsync( + DodoDbContext database, + Guid id, + CancellationToken cancellationToken) => + await database.SshKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken) + .ConfigureAwait(false); + + /// + public async Task> LoadAsync( + DodoDbContext database, + Guid vaultId, + Guid[] ids, + CancellationToken cancellationToken) + { + var rows = await database.SshKeys + .Where(k => k.VaultId == vaultId && ids.Contains(k.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); + } + + /// + public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) + { + var key = new VaultSshKey { Id = id, VaultId = vaultId }; + + database.SshKeys.Add(key); + + return key; + } + + /// + /// Refuses the relay columns outright. + /// + /// + /// A key is not something the server dials, so an address on one is either a client bug or an attempt to + /// get the server to store something it has no reason to hold. Refused with a reason rather than + /// silently dropped, because a client that thinks it is storing a field and is not will eventually be + /// surprised by its absence. + /// + /// + public bool ValidateFields(SyncPlaintextFields fields, out string error) + { + ArgumentNullException.ThrowIfNull(fields); + + error = string.Empty; + + if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) + { + error = "An SSH key has no relay target; relay fields may only be set on a host."; + return false; + } + + return true; + } + + /// + public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) + { + ArgumentNullException.ThrowIfNull(fields); + + // Only the fingerprint, and only because the contract reserved it. Everything that identifies the + // key to a person — its label, its comment — is inside the ciphertext. + ((VaultSshKey)item).PublicKeyFingerprint = fields.PublicKeyFingerprint; + } + + /// + public void ClearFieldsOnDelete(IVaultItem item) => + ((VaultSshKey)item).PublicKeyFingerprint = null; + + /// + /// Null rather than an all-defaults instance, and the difference is visible on the wire: a + /// SyncPlaintextFields with nothing set still serialises relayEnabled: false, which + /// invites a reader to believe this type has a relay setting that happens to be off. It has none. + /// + /// + public SyncPlaintextFields? Hydrate(IVaultItem item) => + ((VaultSshKey)item).PublicKeyFingerprint is { } fingerprint + ? new SyncPlaintextFields(PublicKeyFingerprint: fingerprint) + : null; +} diff --git a/src/DodoSSH.Api/Features/Sync/SyncService.cs b/src/DodoSSH.Api/Features/Sync/SyncService.cs index d4d79fa..ab2ce84 100644 --- a/src/DodoSSH.Api/Features/Sync/SyncService.cs +++ b/src/DodoSSH.Api/Features/Sync/SyncService.cs @@ -198,10 +198,10 @@ internal sealed class SyncService( SyncPushOperation operation, CancellationToken cancellationToken) { - if (operation.EntityType != SyncEntityType.Host) + if (ItemKinds.For(operation.EntityType) is not { } kind) { - // 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. + // Reserved in the contract but not synced here yet. 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."); } @@ -228,13 +228,12 @@ internal sealed class SyncService( Detail: null); } - var host = await database.Hosts - .SingleOrDefaultAsync(h => h.Id == operation.EntityId, cancellationToken) + var item = await kind.FindAsync(database, 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) + if (item is not null && item.VaultId != vault.Id) { return new SyncPushResult( operation.OperationId, @@ -246,17 +245,18 @@ internal sealed class SyncService( } return operation.Operation == SyncOperation.Delete - ? await ApplyDeleteAsync(vault, actorUserId, operation, host, cancellationToken) + ? await ApplyDeleteAsync(vault, kind, actorUserId, operation, item, cancellationToken) .ConfigureAwait(false) - : await ApplyUpsertAsync(vault, actorUserId, operation, host, cancellationToken) + : await ApplyUpsertAsync(vault, kind, actorUserId, operation, item, cancellationToken) .ConfigureAwait(false); } private async Task ApplyUpsertAsync( Vault vault, + IItemKind kind, Guid actorUserId, SyncPushOperation operation, - SshHost? existing, + IVaultItem? existing, CancellationToken cancellationToken) { if (operation.Payload is null) @@ -271,9 +271,9 @@ internal sealed class SyncService( var fields = operation.PlaintextFields ?? new SyncPlaintextFields(); - if (!ValidateRelayFields(fields, out var relayError)) + if (!kind.ValidateFields(fields, out var fieldError)) { - return Invalid(operation, relayError); + return Invalid(operation, fieldError); } var now = clock.GetUtcNow(); @@ -281,30 +281,31 @@ internal sealed class SyncService( if (existing is null || existing.DeletedAtUtc is not null) { return await CreateAsync( - vault, actorUserId, operation, existing, fields, now, cancellationToken) + vault, kind, actorUserId, operation, existing, fields, now, cancellationToken) .ConfigureAwait(false); } if (operation.ExpectedVersion != existing.Version) { - return await ConflictAsync(vault, operation, existing, cancellationToken) + return await ConflictAsync(vault, kind, operation, existing, cancellationToken) .ConfigureAwait(false); } existing.Version++; - ApplyFields(existing, operation.Payload, fields, actorUserId, now); + ApplyFields(kind, existing, operation.Payload, fields, actorUserId, now); return await RecordAsync( - vault, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken) + vault, kind, 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, + IItemKind kind, Guid actorUserId, SyncPushOperation operation, - SshHost? existing, + IVaultItem? existing, SyncPlaintextFields fields, DateTimeOffset now, CancellationToken cancellationToken) @@ -313,39 +314,36 @@ internal sealed class SyncService( // 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) + return await ConflictAsync(vault, kind, 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) + return await ConflictAsync(vault, kind, operation, existing: null, cancellationToken) .ConfigureAwait(false); } - var created = new SshHost - { - Id = operation.EntityId, - VaultId = vault.Id, - Version = 1, - CreatedAtUtc = now, - CreatedByUserId = actorUserId, - }; + var created = kind.Add(database, operation.EntityId, vault.Id); - ApplyFields(created, operation.Payload!, fields, actorUserId, now); - database.Hosts.Add(created); + created.Version = 1; + created.CreatedAtUtc = now; + created.CreatedByUserId = actorUserId; + + ApplyFields(kind, created, operation.Payload!, fields, actorUserId, now); return await RecordAsync( - vault, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken) + vault, kind, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken) .ConfigureAwait(false); } private async Task ApplyDeleteAsync( Vault vault, + IItemKind kind, Guid actorUserId, SyncPushOperation operation, - SshHost? existing, + IVaultItem? existing, CancellationToken cancellationToken) { if (existing is null) @@ -368,7 +366,7 @@ internal sealed class SyncService( if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version) { - return await ConflictAsync(vault, operation, existing, cancellationToken) + return await ConflictAsync(vault, kind, operation, existing, cancellationToken) .ConfigureAwait(false); } @@ -378,36 +376,36 @@ internal sealed class SyncService( 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; + kind.ClearFieldsOnDelete(existing); return await RecordAsync( - vault, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken) + vault, kind, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken) .ConfigureAwait(false); } + /// + /// The ciphertext and the bookkeeping are identical for every item type; only the plaintext columns + /// differ, and those are the type's own business. Splitting it this way is what stops a new item type + /// from having to restate — and possibly misstate — how a payload is stored. + /// private static void ApplyFields( - SshHost host, + IItemKind kind, + IVaultItem item, EncryptedPayload payload, SyncPlaintextFields fields, Guid actorUserId, DateTimeOffset now) { - host.Payload = payload.Envelope; - host.DataKeyWrap = payload.WrappedDataKey; - host.ContentKeyId = payload.DataKeyId; - 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; + item.Payload = payload.Envelope; + item.DataKeyWrap = payload.WrappedDataKey; + item.ContentKeyId = payload.DataKeyId; + item.KeyGeneration = (int)payload.KeyGeneration; + item.PayloadAadVersion = payload.AadVersion; + item.DeletedAtUtc = null; + item.UpdatedAtUtc = now; + item.UpdatedByUserId = actorUserId; + + kind.ApplyFields(item, fields); } /// @@ -445,55 +443,22 @@ internal sealed class SyncService( return true; } - /// - /// 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, + IItemKind kind, Guid actorUserId, SyncPushOperation operation, - SshHost host, + IVaultItem item, ChangeOperation changeOperation, CancellationToken cancellationToken) { var change = new VaultChange { VaultId = vault.Id, - EntityType = ChangeEntityType.SshHost, - EntityId = host.Id, + EntityType = kind.ChangeType, + EntityId = item.Id, Operation = changeOperation, - Revision = host.Version, + Revision = item.Version, ActorUserId = actorUserId, OccurredAtUtc = clock.GetUtcNow(), }; @@ -504,14 +469,14 @@ internal sealed class SyncService( // database on insert. await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - host.ChangeSequence = change.Sequence; + item.ChangeSequence = change.Sequence; database.SyncOperationReceipts.Add(new SyncOperationReceipt { OperationId = operation.OperationId, VaultId = vault.Id, AppliedChangeSequence = change.Sequence, - ResultVersion = host.Version, + ResultVersion = item.Version, CreatedAtUtc = clock.GetUtcNow(), }); @@ -520,7 +485,7 @@ internal sealed class SyncService( return new SyncPushResult( operation.OperationId, SyncOperationStatus.Applied, - host.Version, + item.Version, change.Sequence, ServerEntity: null, Detail: null); @@ -528,8 +493,9 @@ internal sealed class SyncService( private async Task ConflictAsync( Vault vault, + IItemKind kind, SyncPushOperation operation, - SshHost? existing, + IVaultItem? existing, CancellationToken cancellationToken) { // The server cannot merge ciphertext, so it hands back its current state and the client @@ -544,7 +510,7 @@ internal sealed class SyncService( { Sequence = existing.ChangeSequence, VaultId = existing.VaultId, - EntityType = ChangeEntityType.SshHost, + EntityType = kind.ChangeType, EntityId = existing.Id, Operation = existing.DeletedAtUtc is null ? ChangeOperation.Upsert @@ -569,65 +535,80 @@ internal sealed class SyncService( } /// Attaches current row state to change-log entries. + /// + /// A page of changes can mix item types, so rows are loaded per type — one query each, not one per + /// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull + /// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from. + /// 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 rows = new Dictionary<(ChangeEntityType Type, Guid Id), IVaultItem>(); + var kinds = new Dictionary(); - var hosts = hostIds.Length == 0 - ? [] - : await database.Hosts - .Where(h => h.VaultId == vault.Id && hostIds.Contains(h.Id)) - .ToDictionaryAsync(h => h.Id, cancellationToken) + foreach (var group in changes.GroupBy(change => change.EntityType)) + { + if (KindForChange(group.Key) is not { } kind) + { + // A change this server wrote under a type it no longer synchronises. Nothing sensible to + // hydrate, and dropping the entry would leave a gap in the client's cursor — so it is + // emitted below with no payload, which a client already handles as a tombstone. + continue; + } + + kinds[group.Key] = kind; + + var ids = group.Select(change => change.EntityId).Distinct().ToArray(); + + var loaded = await kind.LoadAsync(database, vault.Id, ids, 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, - // Non-null for every row a push can create: ValidatePayload refuses an - // operation without them. The columns stay nullable because they are also - // the seam for M5's per-item grants. - host.DataKeyWrap ?? [], - host.ContentKeyId ?? Guid.Empty, - (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)); + foreach (var (id, item) in loaded) + { + rows[(group.Key, id)] = item; + } } - return result; + return [.. changes.Select(change => Describe( + change, + kinds.GetValueOrDefault(change.EntityType), + rows.GetValueOrDefault((change.EntityType, change.EntityId))))]; } + /// Turns one change-log entry and its row into what a client receives. + private static Contracts.SyncChange Describe(VaultChange change, IItemKind? kind, IVaultItem? item) + { + // 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 || item?.DeletedAtUtc is not null; + var opaque = isDelete || item is null; + + return new Contracts.SyncChange( + EntityType: kind?.WireType ?? ToWire(change.EntityType), + EntityId: change.EntityId, + Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert, + Version: change.Revision, + ChangeSequence: change.Sequence, + Payload: opaque + ? null + : new EncryptedPayload( + item!.Payload, + // Non-null for every row a push can create: ValidatePayload refuses an operation + // without them. The columns stay nullable because they are also the seam for M5's + // per-item grants. + item.DataKeyWrap ?? [], + item.ContentKeyId ?? Guid.Empty, + (uint)item.KeyGeneration, + (byte)item.PayloadAadVersion), + PlaintextFields: opaque || kind is null ? null : kind.Hydrate(item!), + UpdatedAt: change.OccurredAtUtc); + } + + /// The kind behind a change-log entry, or null for a type this server does not synchronise. + private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type)); + private async Task CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) => await database.VaultChanges .Where(c => c.VaultId == vaultId) @@ -645,5 +626,14 @@ internal sealed class SyncService( ServerEntity: null, Detail: detail); + /// + /// The two enums are numerically aligned by hand — and they do not even use the same member names for + /// the same value, Host against SshHost — so nothing in the type system defends this cast. + /// EntityTypeAlignmentTests is what defends it; if it fails, changes would start being filed + /// under another item type's name and a pull would hand clients the wrong ciphertext. + /// private static ChangeEntityType ToDomain(SyncEntityType type) => (ChangeEntityType)(int)type; + + /// + private static SyncEntityType ToWire(ChangeEntityType type) => (SyncEntityType)(int)type; } diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs index 4e7a444..2f479dd 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 SshHost +public sealed class SshHost : IVaultItem { /// Primary key. UUIDv7, generated by the client so items can be created offline. public Guid Id { get; set; } @@ -87,3 +87,84 @@ public sealed class SshHost /// Who last modified it. public Guid UpdatedByUserId { get; set; } } + +/// +/// An SSH key pair, held as ciphertext. +/// +/// +/// +/// The private key, its passphrase, its label and its comment are all inside . This +/// row is the reason the vault is worth having — a key that syncs between a user's machines and can later be +/// shared with a teammate — and it is also the row that would hurt most if the server could read it, so +/// there is deliberately not one plaintext column of substance. +/// +/// +/// Notably absent: the relay trio. A key is not something the server dials, so the plaintext concession +/// ADR 0004 makes for has no analogue here and no reason to exist. That is the whole +/// argument for a separate table rather than one wide item table: the columns a host needs are columns a key +/// must never have, and a shared table could only make them nullable and trust the code. +/// +/// +/// is the single exception, and it stays null until something needs it. +/// The contract reserved the slot for showing which key a host is configured to use without decrypting +/// every key first; write it only when that feature lands, and never derive anything security-relevant from +/// it, because a fingerprint the server stores is a fingerprint the server chose. +/// +/// +public sealed class VaultSshKey : IVaultItem +{ + /// Primary key. UUIDv7, generated by the client so keys can be created offline. + public Guid Id { get; set; } + + /// Owning vault. + public Guid VaultId { get; set; } + + /// Owning vault. + public Vault? Vault { get; set; } + + /// The encrypted key material: a DSH1 envelope. Opaque to the server. + public byte[] Payload { get; set; } = []; + + /// The item's data key, wrapped under the vault key. Opaque. + public byte[]? DataKeyWrap { get; set; } + + /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. + public Guid? ContentKeyId { get; set; } + + /// Vault key generation this payload was encrypted under. + public int KeyGeneration { get; set; } + + /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. + public short PayloadAadVersion { get; set; } + + /// + /// OpenSSH-style SHA256:base64 fingerprint of the public half, when a client publishes it. + /// + /// + /// Plaintext, and therefore opt-in and currently unused. A public key fingerprint is not a secret, but + /// it is an identifier that links a vault to a machine's authorized_keys, so it is stored only + /// when a feature needs it rather than because it is harmless. + /// + public string? PublicKeyFingerprint { get; set; } + + /// Client-visible, monotonic item version, used for expectedVersion checks. + public int Version { get; set; } + + /// Latest change-log sequence touching this row, so a delta pull can join directly. + public long ChangeSequence { get; set; } + + /// Creation timestamp. + public DateTimeOffset CreatedAtUtc { get; set; } + + /// Last modification timestamp. + public DateTimeOffset UpdatedAtUtc { get; set; } + + /// Soft-delete marker; a tombstone, so an offline client learns the key went away. + public DateTimeOffset? DeletedAtUtc { get; set; } + + /// Who created it. + public Guid CreatedByUserId { get; set; } + + /// Who last modified it. + public Guid UpdatedByUserId { get; set; } +} diff --git a/src/DodoSSH.Domain/VaultItems.cs b/src/DodoSSH.Domain/VaultItems.cs new file mode 100644 index 0000000..5b0f40d --- /dev/null +++ b/src/DodoSSH.Domain/VaultItems.cs @@ -0,0 +1,71 @@ +namespace DodoSSH.Domain; + +/// +/// The shape every encrypted vault item shares. +/// +/// +/// +/// Each item type keeps its own table — the plan's data model, and the reason is per-type constraints +/// rather than tidiness: the relay CHECK on host has no meaning for an SSH key, and a single table +/// would have to make every such column nullable and enforce nothing. What the types genuinely share is +/// this: opaque ciphertext, the key generation it was sealed under, a client-visible version for optimistic +/// concurrency, a change-log pointer, and a tombstone. +/// +/// +/// An interface rather than a base class, deliberately. EF Core maps an inheritance hierarchy when it can +/// see one, and a mapped base would quietly become a table-per-hierarchy discriminator across item types — +/// which is the arrangement this interface exists to avoid. An interface carries no mapping semantics, so +/// each concrete type stays an independent entity with its own table while the write path can still be +/// written once. +/// +/// +/// Note what is not here. xmin is never exposed, because it is not stable across +/// VACUUM FREEZE and must not become a client cursor. Plaintext columns are not here either: they +/// are exactly the part that differs per type, and they are the part with privacy consequences, so they are +/// handled explicitly per type rather than through a shared abstraction that could grow one by accident. +/// +/// +public interface IVaultItem +{ + /// Primary key. UUIDv7, generated by the client so items can be created offline. + Guid Id { get; set; } + + /// Owning vault. + Guid VaultId { get; set; } + + /// The encrypted item: a DSH1 envelope. Opaque to the server. + byte[] Payload { get; set; } + + /// The item's data key, wrapped under the vault key. Opaque. + byte[]? DataKeyWrap { get; set; } + + /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. + Guid? ContentKeyId { get; set; } + + /// Vault key generation this payload was encrypted under. + int KeyGeneration { get; set; } + + /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. + short PayloadAadVersion { get; set; } + + /// Client-visible, monotonic item version, used for expectedVersion checks. + int Version { get; set; } + + /// Latest change-log sequence touching this row, so a delta pull can join directly. + long ChangeSequence { get; set; } + + /// Creation timestamp. + DateTimeOffset CreatedAtUtc { get; set; } + + /// Last modification timestamp. + DateTimeOffset UpdatedAtUtc { get; set; } + + /// Soft-delete marker. Deletes are tombstones so an offline client can learn of them. + DateTimeOffset? DeletedAtUtc { get; set; } + + /// Who created it. + Guid CreatedByUserId { get; set; } + + /// Who last modified it. + Guid UpdatedByUserId { get; set; } +} diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs index cc7c282..10e73a2 100644 --- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs +++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs @@ -49,6 +49,43 @@ public sealed class HostConfiguration : IEntityTypeConfiguration } } +/// Maps . +/// +/// Mirrors in everything the two types share, and deliberately has no +/// analogue of the relay CHECK: a key carries no address, which is the reason it is its own table. +/// +public sealed class SshKeyConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("ssh_key"); + builder.HasKey(k => k.Id); + + // Client-generated UUIDv7: keys must be creatable offline, with their ids. + builder.Property(k => k.Id).ValueGeneratedNever(); + builder.UseXminConcurrencyToken(); + + builder.Property(k => k.Payload).IsRequired(); + + // SHA256:base64 of a 32-byte digest is 50 characters; the ceiling leaves room for another + // algorithm without a migration, and refuses anything that is plainly not a fingerprint. + builder.Property(k => k.PublicKeyFingerprint).HasMaxLength(128); + + builder.HasIndex(k => new { k.VaultId, k.ChangeSequence }); + + builder.HasIndex(k => k.VaultId) + .HasFilter("deleted_at_utc IS NULL") + .HasDatabaseName("ix_ssh_key_vault_live"); + + builder.ToTable(t => t.HasCheckConstraint( + "ck_ssh_key_version", + "version >= 1")); + } +} + /// Maps . public sealed class SyncChangeConfiguration : IEntityTypeConfiguration { diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs index 19ffa21..ef18aa3 100644 --- a/src/DodoSSH.Infrastructure/DodoDbContext.cs +++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs @@ -54,6 +54,9 @@ public class DodoDbContext(DbContextOptions options) : DbContext( /// SSH hosts. public DbSet Hosts => Set(); + /// SSH key pairs, held as ciphertext. + public DbSet SshKeys => Set(); + /// The per-vault change log that delta sync reads. public DbSet VaultChanges => Set(); diff --git a/src/DodoSSH.Infrastructure/Migrations/20260729130834_AddSshKeyItem.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260729130834_AddSshKeyItem.Designer.cs new file mode 100644 index 0000000..421bf86 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260729130834_AddSshKeyItem.Designer.cs @@ -0,0 +1,1069 @@ +// +using System; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + [DbContext(typeof(DodoDbContext))] + [Migration("20260729130834_AddSshKeyItem")] + partial class AddSshKeyItem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dodo") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DodoSSH.Domain.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EnrolledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at_utc"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Platform") + .HasColumnType("integer") + .HasColumnName("platform"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("public_key"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_device"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_device_user_id"); + + b.ToTable("device", "dodo"); + }); + + 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") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("Hostname") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("hostname"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("Port") + .HasColumnType("integer") + .HasColumnName("port"); + + b.Property("RelayEnabled") + .HasColumnType("boolean") + .HasColumnName("relay_enabled"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_host"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_host_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_host_vault_id_change_sequence"); + + b.ToTable("host", "dodo", t => + { + t.HasCheckConstraint("ck_host_port_range", "port IS NULL OR (port BETWEEN 1 AND 65535)"); + + t.HasCheckConstraint("ck_host_relay_target", "(relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)\nOR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)"); + + t.HasCheckConstraint("ck_host_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b => + { + b.Property("OperationId") + .HasColumnType("uuid") + .HasColumnName("operation_id"); + + b.Property("AppliedChangeSequence") + .HasColumnType("bigint") + .HasColumnName("applied_change_sequence"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("ResultVersion") + .HasColumnType("integer") + .HasColumnName("result_version"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.HasKey("OperationId") + .HasName("pk_sync_operation_receipt"); + + b.HasIndex("VaultId", "CreatedAtUtc") + .HasDatabaseName("ix_sync_operation_receipt_vault_id_created_at_utc"); + + b.ToTable("sync_operation_receipt", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Team", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("citext") + .HasColumnName("slug"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_team"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_team_slug") + .HasFilter("deleted_at_utc IS NULL"); + + b.ToTable("team", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid") + .HasColumnName("invited_by_user_id"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at_utc"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_team_membership"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_team_membership_user_id"); + + b.HasIndex("TeamId", "UserId") + .IsUnique() + .HasDatabaseName("ix_team_membership_team_id_user_id") + .HasFilter("deleted_at_utc IS NULL"); + + b.ToTable("team_membership", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserAccount", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("EnrolledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at_utc"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("issuer"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at_utc"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("subject"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_user_account"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_user_account_email") + .HasFilter("email IS NOT NULL AND deleted_at_utc IS NULL"); + + b.HasIndex("Issuer", "Subject") + .IsUnique() + .HasDatabaseName("ix_user_account_issuer_subject"); + + b.ToTable("user_account", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + 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("FingerprintSha256") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("fingerprint_sha256"); + + b.Property("Generation") + .HasColumnType("integer") + .HasColumnName("generation"); + + b.Property("IdentityProviderBinding") + .HasColumnType("jsonb") + .HasColumnName("identity_provider_binding"); + + b.Property("IsCurrent") + .HasColumnType("boolean") + .HasColumnName("is_current"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("SigningPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("signing_public_key"); + + b.Property("Statement") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("statement"); + + b.Property("StatementSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("statement_signature"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_key"); + + b.HasIndex("FingerprintSha256") + .IsUnique() + .HasDatabaseName("ix_user_key_fingerprint_sha256"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_user_key_current") + .HasFilter("is_current"); + + b.HasIndex("UserId", "Generation") + .IsUnique() + .HasDatabaseName("ix_user_key_user_id_generation"); + + b.ToTable("user_key", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("KdfAlgorithm") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("kdf_algorithm"); + + b.Property("KdfMemoryKibibytes") + .HasColumnType("integer") + .HasColumnName("kdf_memory_kibibytes"); + + b.Property("KdfParallelism") + .HasColumnType("integer") + .HasColumnName("kdf_parallelism"); + + b.Property("KdfPasses") + .HasColumnType("integer") + .HasColumnName("kdf_passes"); + + b.Property("KdfSalt") + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("kdf_salt"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at_utc"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Wrap") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("wrap"); + + b.Property("WrapVersion") + .HasColumnType("integer") + .HasColumnName("wrap_version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_user_key_wrap"); + + b.HasIndex("DeviceId") + .HasDatabaseName("ix_user_key_wrap_device_id"); + + b.HasIndex("UserId", "DeviceId") + .IsUnique() + .HasDatabaseName("ix_user_key_wrap_user_device") + .HasFilter("device_id IS NOT NULL"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasDatabaseName("ix_user_key_wrap_user_kind") + .HasFilter("device_id IS NULL"); + + b.ToTable("user_key_wrap", "dodo", t => + { + t.HasCheckConstraint("ck_user_key_wrap_device", "(kind = 2 AND device_id IS NOT NULL) OR (kind <> 2 AND device_id IS NULL)"); + + t.HasCheckConstraint("ck_user_key_wrap_kdf", "(kind IN (1, 3) AND kdf_algorithm IS NOT NULL AND kdf_salt IS NOT NULL\n AND kdf_memory_kibibytes IS NOT NULL AND kdf_passes IS NOT NULL\n AND kdf_parallelism IS NOT NULL)\nOR (kind IN (2, 4) AND kdf_algorithm IS NULL AND kdf_salt IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerKind") + .HasColumnType("integer") + .HasColumnName("owner_kind"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("RekeyReason") + .HasColumnType("integer") + .HasColumnName("rekey_reason"); + + b.Property("RekeyRequired") + .HasColumnType("boolean") + .HasColumnName("rekey_required"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_vault"); + + b.HasIndex("OwnerUserId") + .HasDatabaseName("ix_vault_owner_user_id"); + + b.HasIndex("TeamId") + .HasDatabaseName("ix_vault_team_id"); + + b.ToTable("vault", "dodo", t => + { + t.HasCheckConstraint("ck_vault_key_generation", "key_generation >= 1"); + + t.HasCheckConstraint("ck_vault_owner", "(owner_kind = 1 AND owner_user_id IS NOT NULL AND team_id IS NULL)\nOR (owner_kind = 2 AND team_id IS NOT NULL AND owner_user_id IS NULL)"); + }); + }); + + 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") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("GranterKeyFingerprint") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("granter_key_fingerprint"); + + b.Property("GranterUserId") + .HasColumnType("uuid") + .HasColumnName("granter_user_id"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("KeyLogHead") + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("key_log_head"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("RecipientKeyFingerprint") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("recipient_key_fingerprint"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("recipient_user_id"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("signature"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("state"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("WrappedKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("wrapped_key"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_vault_key_grant"); + + b.HasIndex("RecipientUserId") + .HasDatabaseName("ix_vault_key_grant_recipient_user_id"); + + b.HasIndex("VaultId", "KeyGeneration", "RecipientUserId") + .IsUnique() + .HasDatabaseName("ix_vault_key_grant_vault_id_key_generation_recipient_user_id") + .HasFilter("revoked_at_utc IS NULL AND recipient_user_id IS NOT NULL"); + + b.ToTable("vault_key_grant", "dodo", t => + { + t.HasCheckConstraint("ck_vault_key_grant_recipient", "(kind = 1 AND recipient_user_id IS NOT NULL) OR (kind <> 1 AND recipient_user_id IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("PublicKeyFingerprint") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("public_key_fingerprint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_ssh_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_ssh_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_ssh_key_vault_id_change_sequence"); + + b.ToTable("ssh_key", "dodo", t => + { + t.HasCheckConstraint("ck_ssh_key_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.Device", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("Devices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.SshHost", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany("Hosts") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_host_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => + { + b.HasOne("DodoSSH.Domain.Team", "Team") + .WithMany("Memberships") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_team_membership_team_team_id"); + + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_team_membership_users_user_id"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKey", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("Keys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_key_user_account_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b => + { + b.HasOne("DodoSSH.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_key_wrap_device_device_id"); + + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("KeyWraps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_key_wrap_user_account_user_id"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vault_user_account_owner_user_id"); + + b.HasOne("DodoSSH.Domain.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vault_team_team_id"); + + b.Navigation("OwnerUser"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_vault_key_grant_user_account_recipient_user_id"); + + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany("KeyGrants") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vault_key_grant_vault_vault_id"); + + b.Navigation("RecipientUser"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ssh_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Team", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserAccount", b => + { + b.Navigation("Devices"); + + b.Navigation("KeyWraps"); + + b.Navigation("Keys"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.Navigation("Hosts"); + + b.Navigation("KeyGrants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/20260729130834_AddSshKeyItem.cs b/src/DodoSSH.Infrastructure/Migrations/20260729130834_AddSshKeyItem.cs new file mode 100644 index 0000000..fed421b --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260729130834_AddSshKeyItem.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + /// + public partial class AddSshKeyItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ssh_key", + schema: "dodo", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + vault_id = table.Column(type: "uuid", nullable: false), + payload = table.Column(type: "bytea", nullable: false), + data_key_wrap = table.Column(type: "bytea", nullable: true), + content_key_id = table.Column(type: "uuid", nullable: true), + key_generation = table.Column(type: "integer", nullable: false), + payload_aad_version = table.Column(type: "smallint", nullable: false), + public_key_fingerprint = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + version = table.Column(type: "integer", nullable: false), + change_sequence = table.Column(type: "bigint", nullable: false), + created_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + updated_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + created_by_user_id = table.Column(type: "uuid", nullable: false), + updated_by_user_id = table.Column(type: "uuid", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_ssh_key", x => x.id); + table.CheckConstraint("ck_ssh_key_version", "version >= 1"); + table.ForeignKey( + name: "fk_ssh_key_vaults_vault_id", + column: x => x.vault_id, + principalSchema: "dodo", + principalTable: "vault", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_ssh_key_vault_id_change_sequence", + schema: "dodo", + table: "ssh_key", + columns: new[] { "vault_id", "change_sequence" }); + + migrationBuilder.CreateIndex( + name: "ix_ssh_key_vault_live", + schema: "dodo", + table: "ssh_key", + column: "vault_id", + filter: "deleted_at_utc IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ssh_key", + schema: "dodo"); + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs index b57000d..dd1a92f 100644 --- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs +++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs @@ -826,6 +826,92 @@ namespace DodoSSH.Infrastructure.Migrations }); }); + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("PublicKeyFingerprint") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("public_key_fingerprint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_ssh_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_ssh_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_ssh_key_vault_id_change_sequence"); + + b.ToTable("ssh_key", "dodo", t => + { + t.HasCheckConstraint("ck_ssh_key_version", "version >= 1"); + }); + }); + modelBuilder.Entity("DodoSSH.Domain.Device", b => { b.HasOne("DodoSSH.Domain.UserAccount", "User") @@ -942,6 +1028,18 @@ namespace DodoSSH.Infrastructure.Migrations b.Navigation("Vault"); }); + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ssh_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + modelBuilder.Entity("DodoSSH.Domain.Team", b => { b.Navigation("Memberships"); diff --git a/tests/DodoSSH.Api.Tests/EntityTypeAlignmentTests.cs b/tests/DodoSSH.Api.Tests/EntityTypeAlignmentTests.cs new file mode 100644 index 0000000..c428551 --- /dev/null +++ b/tests/DodoSSH.Api.Tests/EntityTypeAlignmentTests.cs @@ -0,0 +1,78 @@ +using DodoSSH.Contracts; +using DodoSSH.Domain; + +namespace DodoSSH.Api.Tests; + +/// +/// The two entity-type enums have to agree, and nothing but this makes them. +/// +/// +/// +/// SyncService converts between the wire's and the change log's +/// with a raw (ChangeEntityType)(int) cast in both directions. That +/// works only because two independently maintained enums in two assemblies happen to number their members +/// identically — and they do not even name them identically: Host = 1 against SshHost = 1. +/// +/// +/// If they ever drift, nothing fails loudly. Changes get filed in the log under a different item type's +/// name, and the next delta pull loads rows of the wrong type — or none — for entities the client asked +/// about. That is silent data loss on the sync path, which is precisely the failure this project has +/// designed everything else to avoid, so the alignment gets a test rather than a comment. +/// +/// +/// Numeric alignment only. The two lists are deliberately not aligned with +/// CryptoSpec.AadResourceType, which also carries None/User/Device/Vault and therefore numbers the +/// same item types differently — asserting three-way equality would be asserting something false. +/// +/// +public sealed class EntityTypeAlignmentTests +{ + [Fact] + public void EveryWireEntityType_HasAChangeLogTypeWithTheSameValue() + { + var changeValues = Enum.GetValues().Select(value => (int)value).ToHashSet(); + + foreach (var wire in Enum.GetValues()) + { + changeValues.ShouldContain( + (int)wire, + $"SyncEntityType.{wire} = {(int)wire} has no ChangeEntityType with that value, so " + + "SyncService's cast would produce an undefined enum value."); + } + } + + [Fact] + public void EveryChangeLogType_HasAWireTypeWithTheSameValue() + { + // The other direction matters just as much: a pull converts stored changes back to wire types, so a + // change-log type with no wire counterpart would be served to clients as an undefined enum. + var wireValues = Enum.GetValues().Select(value => (int)value).ToHashSet(); + + foreach (var change in Enum.GetValues()) + { + wireValues.ShouldContain( + (int)change, + $"ChangeEntityType.{change} = {(int)change} has no SyncEntityType with that value."); + } + } + + [Fact] + public void TheTwoEnums_HaveTheSameNumberOfMembers() + { + // Catches a member added to one list only, which the two checks above would miss if it reused a + // value already present in the other. + Enum.GetValues().Length.ShouldBe(Enum.GetValues().Length); + } + + /// + /// Spot-checked by name as well, because the pairing that is easiest to get wrong is the one whose names + /// differ. Someone adding an item type may reasonably assume the lists are name-matched, notice + /// Host has no Host on the other side, and renumber to "fix" it. + /// + [Fact] + public void TheDifferentlyNamedPair_IsTheOneThatMatches() + { + ((int)SyncEntityType.Host).ShouldBe((int)ChangeEntityType.SshHost); + ((int)SyncEntityType.SshKey).ShouldBe((int)ChangeEntityType.SshKey); + } +} diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index f0beec1..c7afe18 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -608,6 +608,145 @@ public sealed class SyncEndpointTests(ApiFixture fixture) // Just-in-time provisioning is covered by IdentityEndpointTests, against /me — the endpoint a // client actually calls first, and the only one reachable before enrollment. + // ---- SSH keys ---- + + [Fact] + public async Task AnSshKey_RoundTripsWithNoPlaintextFields() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var keyId = Guid.CreateVersion7(); + + var pushed = await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest([KeyOperation(keyId, expectedVersion: null, envelope: [9, 8, 7])])); + + var results = await pushed.Content.ReadContractAsync(); + results.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied); + + var pulled = await client.PostContractAsync( + PullUrl(vaultId), + new SyncPullRequest(null, null, [SyncEntityType.SshKey])); + + var page = await pulled.Content.ReadContractAsync(); + var change = page.Changes.ShouldHaveSingleItem(); + + change.EntityType.ShouldBe(SyncEntityType.SshKey); + change.EntityId.ShouldBe(keyId); + change.Payload.ShouldNotBeNull().Envelope.ShouldBe(new byte[] { 9, 8, 7 }); + + // The point of the type. A key has no relay, so it has no plaintext columns at all — and null + // rather than an all-defaults instance, which would still put "relayEnabled": false on the wire and + // invite a reader to think the setting exists and is off. + change.PlaintextFields.ShouldBeNull(); + } + + [Fact] + public async Task AnSshKeyCarryingARelayTarget_IsRefused() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var operation = KeyOperation(Guid.CreateVersion7(), null, [1]) + with + { PlaintextFields = new SyncPlaintextFields(RelayEnabled: true, Hostname: "db.internal", Port: 22) }; + + var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation])); + + var result = (await pushed.Content.ReadContractAsync()).Results.ShouldHaveSingleItem(); + + result.Status.ShouldBe(SyncOperationStatus.Invalid); + result.Detail.ShouldContain("no relay target"); + } + + /// + /// The path most likely to break: a page of changes mixing item types has to load each type's rows + /// separately and then put them back in the log's order, because a client's cursor cannot resume from a + /// sequence that was regrouped. + /// + [Fact] + public async Task APullMixingHostsAndKeys_ReturnsBothInLogOrder() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var hostId = Guid.CreateVersion7(); + var keyId = Guid.CreateVersion7(); + + var pushed = await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest( + [ + NewOperation(hostId, expectedVersion: null, envelope: [1, 1]), + KeyOperation(keyId, expectedVersion: null, envelope: [2, 2]), + ])); + + (await pushed.Content.ReadContractAsync()).Results + .ShouldAllBe(result => result.Status == SyncOperationStatus.Applied); + + var pulled = await client.PostContractAsync( + PullUrl(vaultId), + new SyncPullRequest(null, null, null)); + + var page = await pulled.Content.ReadContractAsync(); + + page.Changes.Count.ShouldBe(2); + page.Changes.Select(change => change.ChangeSequence) + .ShouldBeInOrder(Shouldly.SortDirection.Ascending); + + var host = page.Changes.Single(change => change.EntityId == hostId); + var key = page.Changes.Single(change => change.EntityId == keyId); + + host.EntityType.ShouldBe(SyncEntityType.Host); + host.Payload.ShouldNotBeNull().Envelope.ShouldBe(new byte[] { 1, 1 }); + host.PlaintextFields.ShouldNotBeNull(); + + key.EntityType.ShouldBe(SyncEntityType.SshKey); + key.Payload.ShouldNotBeNull().Envelope.ShouldBe(new byte[] { 2, 2 }); + key.PlaintextFields.ShouldBeNull(); + } + + [Fact] + public async Task DeletingAnSshKey_TombstonesItWithoutAPayload() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var keyId = Guid.CreateVersion7(); + + await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest([KeyOperation(keyId, null, [5])])); + + var deleted = await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest( + [ + new SyncPushOperation( + Guid.CreateVersion7(), + SyncEntityType.SshKey, + keyId, + SyncOperation.Delete, + ExpectedVersion: 1, + Payload: null, + PlaintextFields: null), + ])); + + (await deleted.Content.ReadContractAsync()).Results + .ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied); + + var pulled = await client.PostContractAsync( + PullUrl(vaultId), + new SyncPullRequest(null, null, [SyncEntityType.SshKey])); + + var page = await pulled.Content.ReadContractAsync(); + var last = page.Changes[^1]; + + last.Operation.ShouldBe(SyncOperation.Delete); + last.Payload.ShouldBeNull("a tombstone must not ship the key material it replaced"); + } + // ---- Helpers ---- private static string PullUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/pull"; @@ -633,6 +772,20 @@ public sealed class SyncEndpointTests(ApiFixture fixture) Payload(envelope), new SyncPlaintextFields()); + /// + /// PlaintextFields: null rather than an empty instance, which is what a real client sends for a + /// type with no plaintext columns — and what the server must accept without inventing defaults. + /// + private static SyncPushOperation KeyOperation(Guid entityId, int? expectedVersion, byte[] envelope) => + new( + Guid.CreateVersion7(), + SyncEntityType.SshKey, + entityId, + SyncOperation.Upsert, + expectedVersion, + Payload(envelope), + PlaintextFields: null); + private static SyncPushRequest NewCreateBatch() => new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);