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])]);