Public Access
Sync SSH keys as a vault item type, over a shared write path
The private key now lives in the vault as ciphertext, syncs between a user's machines, and is stored on the server so it can later be shared — sharing itself needs M3's signed grants; this is the storage that makes it possible. More was already reserved than expected: SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey, ChangeEntityType.SshKey, SyncPlaintextFields.PublicKeyFingerprint, and SshPrivateKeyCredential wired through PrivateKeyFile over a MemoryStream so a key never touches disk. The frozen contract and crypto spec needed no change at all. What was missing was the server. Rather than copy the push path per item type — version check, change-log append, exactly-once receipt, advisory lock — it is now written once over IVaultItem, with everything type-specific behind IItemKind: which table, which plaintext columns, and what those columns must satisfy. Ten copies of that logic by M5, with a fix applied to nine, is the outcome this avoids. The refactor landed first with no behaviour change, so all 66 existing Host tests were the regression net, and they stayed green. An interface rather than a base class, deliberately: EF Core maps an inheritance hierarchy when it can see one, so a mapped base would quietly become a table-per-hierarchy discriminator across item types — the very arrangement per-type tables exist to avoid. ssh_key mirrors host and pointedly has no relay trio. That is the argument for separate tables 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. A key carrying a relay target is refused with a reason rather than silently dropped. A key hydrates PlaintextFields as null, not an empty instance — the difference is visible on the wire, because an all-defaults instance still serialises "relayEnabled": false and invites a reader to believe the setting exists and is off. It has none. Two things now defended by tests rather than by comments. Each kind states its own ChangeEntityType instead of casting: the two enums agree numerically but do not even share member names (Host against SshHost), and filing key changes under the host type is silent sync corruption — sabotaging it fails three tests. And EntityTypeAlignmentTests asserts the two enums stay aligned in both directions and in count, which nothing did before. The client half is next: SshKeySecret, its codec and merge, the cipher, a repository, and the UI. Note for that work — SyncEntityType.SshKey is 3 while AadResourceType.SshKey is 6, so a cast between them would seal key ciphertext as a vault and nothing would fail.
This commit is contained in:
@@ -0,0 +1,294 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Domain;
|
||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything about one item type that the shared write path cannot know.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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 <see cref="IVaultItem"/>, and everything genuinely type-specific —
|
||||||
|
/// which table, which plaintext columns, what those columns must satisfy — arrives through here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately not generic. A generic <c>IItemKind<TItem></c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal interface IItemKind
|
||||||
|
{
|
||||||
|
/// <summary>The type as the wire contract names it.</summary>
|
||||||
|
SyncEntityType WireType { get; }
|
||||||
|
|
||||||
|
/// <summary>The type as the change log names it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Stated rather than cast. The two enums do agree numerically — and they have different member names
|
||||||
|
/// for the same value, <c>Host</c> against <c>SshHost</c> — 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.
|
||||||
|
/// </remarks>
|
||||||
|
ChangeEntityType ChangeType { get; }
|
||||||
|
|
||||||
|
/// <summary>Finds one item by id, across every vault, so a cross-vault id can be refused.</summary>
|
||||||
|
Task<IVaultItem?> FindAsync(DodoDbContext database, Guid id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Loads the rows behind a set of change-log entries, scoped to one vault.</summary>
|
||||||
|
Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||||
|
DodoDbContext database,
|
||||||
|
Guid vaultId,
|
||||||
|
Guid[] ids,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Creates an empty row of this type and tracks it.</summary>
|
||||||
|
IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId);
|
||||||
|
|
||||||
|
/// <summary>Rejects plaintext fields this type may not carry.</summary>
|
||||||
|
bool ValidateFields(SyncPlaintextFields fields, out string error);
|
||||||
|
|
||||||
|
/// <summary>Copies this type's plaintext columns out of the request.</summary>
|
||||||
|
void ApplyFields(IVaultItem item, SyncPlaintextFields fields);
|
||||||
|
|
||||||
|
/// <summary>Clears plaintext columns that must not outlive the item.</summary>
|
||||||
|
void ClearFieldsOnDelete(IVaultItem item);
|
||||||
|
|
||||||
|
/// <summary>Reads this type's plaintext columns back for a pull, or null when it has none.</summary>
|
||||||
|
SyncPlaintextFields? Hydrate(IVaultItem item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The item types this server can synchronise, by wire type.</summary>
|
||||||
|
internal static class ItemKinds
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<SyncEntityType, IItemKind> Supported =
|
||||||
|
new[] { (IItemKind)new HostKind(), new SshKeyKind() }
|
||||||
|
.ToDictionary(kind => kind.WireType);
|
||||||
|
|
||||||
|
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <c>Invalid</c> result
|
||||||
|
/// with a reason, not a failed batch that also rejects the operations this server did understand.
|
||||||
|
/// </remarks>
|
||||||
|
internal static IItemKind? For(SyncEntityType type) =>
|
||||||
|
Supported.TryGetValue(type, out var kind) ? kind : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hosts: the one item type with a deliberate plaintext concession.</summary>
|
||||||
|
internal sealed class HostKind : IItemKind
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public SyncEntityType WireType => SyncEntityType.Host;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ChangeEntityType ChangeType => ChangeEntityType.SshHost;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IVaultItem?> FindAsync(
|
||||||
|
DodoDbContext database,
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await database.Hosts.SingleOrDefaultAsync(h => h.Id == id, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Dictionary<Guid, IVaultItem>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||||
|
{
|
||||||
|
var host = new SshHost { Id = id, VaultId = vaultId };
|
||||||
|
|
||||||
|
database.Hosts.Add(host);
|
||||||
|
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint
|
||||||
|
/// violation surfacing as a 500.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The address goes with the item. Leaving it would keep the server able to resolve a host the user
|
||||||
|
/// believes they deleted.
|
||||||
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ClearFieldsOnDelete(IVaultItem item)
|
||||||
|
{
|
||||||
|
var host = (SshHost)item;
|
||||||
|
|
||||||
|
host.RelayEnabled = false;
|
||||||
|
host.Hostname = null;
|
||||||
|
host.Port = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public SyncPlaintextFields? Hydrate(IVaultItem item)
|
||||||
|
{
|
||||||
|
var host = (SshHost)item;
|
||||||
|
|
||||||
|
return new SyncPlaintextFields(
|
||||||
|
RelayEnabled: host.RelayEnabled,
|
||||||
|
Hostname: host.Hostname,
|
||||||
|
Port: host.Port,
|
||||||
|
GroupId: host.GroupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SSH keys: ciphertext and nothing else.</summary>
|
||||||
|
internal sealed class SshKeyKind : IItemKind
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public SyncEntityType WireType => SyncEntityType.SshKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ChangeEntityType ChangeType => ChangeEntityType.SshKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IVaultItem?> FindAsync(
|
||||||
|
DodoDbContext database,
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await database.SshKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Dictionary<Guid, IVaultItem>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||||
|
{
|
||||||
|
var key = new VaultSshKey { Id = id, VaultId = vaultId };
|
||||||
|
|
||||||
|
database.SshKeys.Add(key);
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses the relay columns outright.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ClearFieldsOnDelete(IVaultItem item) =>
|
||||||
|
((VaultSshKey)item).PublicKeyFingerprint = null;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Null rather than an all-defaults instance, and the difference is visible on the wire: a
|
||||||
|
/// <c>SyncPlaintextFields</c> with nothing set still serialises <c>relayEnabled: false</c>, which
|
||||||
|
/// invites a reader to believe this type has a relay setting that happens to be off. It has none.
|
||||||
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public SyncPlaintextFields? Hydrate(IVaultItem item) =>
|
||||||
|
((VaultSshKey)item).PublicKeyFingerprint is { } fingerprint
|
||||||
|
? new SyncPlaintextFields(PublicKeyFingerprint: fingerprint)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -198,10 +198,10 @@ internal sealed class SyncService(
|
|||||||
SyncPushOperation operation,
|
SyncPushOperation operation,
|
||||||
CancellationToken cancellationToken)
|
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
|
// Reserved in the contract but not synced here yet. A newer client gets a precise
|
||||||
// gets a precise per-operation answer rather than a whole-batch failure.
|
// per-operation answer rather than a whole-batch failure.
|
||||||
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
|
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,13 +228,12 @@ internal sealed class SyncService(
|
|||||||
Detail: null);
|
Detail: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var host = await database.Hosts
|
var item = await kind.FindAsync(database, operation.EntityId, cancellationToken)
|
||||||
.SingleOrDefaultAsync(h => h.Id == operation.EntityId, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// An id that exists in another vault must not be addressable from this one, and must not
|
// An id that exists in another vault must not be addressable from this one, and must not
|
||||||
// reveal that it exists elsewhere.
|
// 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(
|
return new SyncPushResult(
|
||||||
operation.OperationId,
|
operation.OperationId,
|
||||||
@@ -246,17 +245,18 @@ internal sealed class SyncService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return operation.Operation == SyncOperation.Delete
|
return operation.Operation == SyncOperation.Delete
|
||||||
? await ApplyDeleteAsync(vault, actorUserId, operation, host, cancellationToken)
|
? await ApplyDeleteAsync(vault, kind, actorUserId, operation, item, cancellationToken)
|
||||||
.ConfigureAwait(false)
|
.ConfigureAwait(false)
|
||||||
: await ApplyUpsertAsync(vault, actorUserId, operation, host, cancellationToken)
|
: await ApplyUpsertAsync(vault, kind, actorUserId, operation, item, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SyncPushResult> ApplyUpsertAsync(
|
private async Task<SyncPushResult> ApplyUpsertAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
|
IItemKind kind,
|
||||||
Guid actorUserId,
|
Guid actorUserId,
|
||||||
SyncPushOperation operation,
|
SyncPushOperation operation,
|
||||||
SshHost? existing,
|
IVaultItem? existing,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (operation.Payload is null)
|
if (operation.Payload is null)
|
||||||
@@ -271,9 +271,9 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
|
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();
|
var now = clock.GetUtcNow();
|
||||||
@@ -281,30 +281,31 @@ internal sealed class SyncService(
|
|||||||
if (existing is null || existing.DeletedAtUtc is not null)
|
if (existing is null || existing.DeletedAtUtc is not null)
|
||||||
{
|
{
|
||||||
return await CreateAsync(
|
return await CreateAsync(
|
||||||
vault, actorUserId, operation, existing, fields, now, cancellationToken)
|
vault, kind, actorUserId, operation, existing, fields, now, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.ExpectedVersion != existing.Version)
|
if (operation.ExpectedVersion != existing.Version)
|
||||||
{
|
{
|
||||||
return await ConflictAsync(vault, operation, existing, cancellationToken)
|
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.Version++;
|
existing.Version++;
|
||||||
ApplyFields(existing, operation.Payload, fields, actorUserId, now);
|
ApplyFields(kind, existing, operation.Payload, fields, actorUserId, now);
|
||||||
|
|
||||||
return await RecordAsync(
|
return await RecordAsync(
|
||||||
vault, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken)
|
vault, kind, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new item, or rejects an upsert that cannot become one.</summary>
|
/// <summary>Creates a new item, or rejects an upsert that cannot become one.</summary>
|
||||||
private async Task<SyncPushResult> CreateAsync(
|
private async Task<SyncPushResult> CreateAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
|
IItemKind kind,
|
||||||
Guid actorUserId,
|
Guid actorUserId,
|
||||||
SyncPushOperation operation,
|
SyncPushOperation operation,
|
||||||
SshHost? existing,
|
IVaultItem? existing,
|
||||||
SyncPlaintextFields fields,
|
SyncPlaintextFields fields,
|
||||||
DateTimeOffset now,
|
DateTimeOffset now,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -313,39 +314,36 @@ internal sealed class SyncService(
|
|||||||
// deliberately under a new id rather than silently undoing someone else's delete.
|
// deliberately under a new id rather than silently undoing someone else's delete.
|
||||||
if (existing?.DeletedAtUtc is not null)
|
if (existing?.DeletedAtUtc is not null)
|
||||||
{
|
{
|
||||||
return await ConflictAsync(vault, operation, existing, cancellationToken)
|
return await ConflictAsync(vault, kind, operation, existing, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.ExpectedVersion is not null)
|
if (operation.ExpectedVersion is not null)
|
||||||
{
|
{
|
||||||
// The client believes it is updating something that does not exist here.
|
// 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);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var created = new SshHost
|
var created = kind.Add(database, operation.EntityId, vault.Id);
|
||||||
{
|
|
||||||
Id = operation.EntityId,
|
|
||||||
VaultId = vault.Id,
|
|
||||||
Version = 1,
|
|
||||||
CreatedAtUtc = now,
|
|
||||||
CreatedByUserId = actorUserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
ApplyFields(created, operation.Payload!, fields, actorUserId, now);
|
created.Version = 1;
|
||||||
database.Hosts.Add(created);
|
created.CreatedAtUtc = now;
|
||||||
|
created.CreatedByUserId = actorUserId;
|
||||||
|
|
||||||
|
ApplyFields(kind, created, operation.Payload!, fields, actorUserId, now);
|
||||||
|
|
||||||
return await RecordAsync(
|
return await RecordAsync(
|
||||||
vault, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken)
|
vault, kind, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SyncPushResult> ApplyDeleteAsync(
|
private async Task<SyncPushResult> ApplyDeleteAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
|
IItemKind kind,
|
||||||
Guid actorUserId,
|
Guid actorUserId,
|
||||||
SyncPushOperation operation,
|
SyncPushOperation operation,
|
||||||
SshHost? existing,
|
IVaultItem? existing,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
@@ -368,7 +366,7 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
|
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);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,36 +376,36 @@ internal sealed class SyncService(
|
|||||||
existing.UpdatedAtUtc = now;
|
existing.UpdatedAtUtc = now;
|
||||||
existing.UpdatedByUserId = actorUserId;
|
existing.UpdatedByUserId = actorUserId;
|
||||||
|
|
||||||
// The address must go with the item. Leaving it would keep the server able to resolve a
|
kind.ClearFieldsOnDelete(existing);
|
||||||
// host the user believes they deleted.
|
|
||||||
existing.RelayEnabled = false;
|
|
||||||
existing.Hostname = null;
|
|
||||||
existing.Port = null;
|
|
||||||
|
|
||||||
return await RecordAsync(
|
return await RecordAsync(
|
||||||
vault, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken)
|
vault, kind, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
private static void ApplyFields(
|
private static void ApplyFields(
|
||||||
SshHost host,
|
IItemKind kind,
|
||||||
|
IVaultItem item,
|
||||||
EncryptedPayload payload,
|
EncryptedPayload payload,
|
||||||
SyncPlaintextFields fields,
|
SyncPlaintextFields fields,
|
||||||
Guid actorUserId,
|
Guid actorUserId,
|
||||||
DateTimeOffset now)
|
DateTimeOffset now)
|
||||||
{
|
{
|
||||||
host.Payload = payload.Envelope;
|
item.Payload = payload.Envelope;
|
||||||
host.DataKeyWrap = payload.WrappedDataKey;
|
item.DataKeyWrap = payload.WrappedDataKey;
|
||||||
host.ContentKeyId = payload.DataKeyId;
|
item.ContentKeyId = payload.DataKeyId;
|
||||||
host.KeyGeneration = (int)payload.KeyGeneration;
|
item.KeyGeneration = (int)payload.KeyGeneration;
|
||||||
host.PayloadAadVersion = payload.AadVersion;
|
item.PayloadAadVersion = payload.AadVersion;
|
||||||
host.RelayEnabled = fields.RelayEnabled;
|
item.DeletedAtUtc = null;
|
||||||
host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
|
item.UpdatedAtUtc = now;
|
||||||
host.Port = fields.RelayEnabled ? fields.Port : null;
|
item.UpdatedByUserId = actorUserId;
|
||||||
host.GroupId = fields.GroupId;
|
|
||||||
host.DeletedAtUtc = null;
|
kind.ApplyFields(item, fields);
|
||||||
host.UpdatedAtUtc = now;
|
|
||||||
host.UpdatedByUserId = actorUserId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -445,55 +443,22 @@ internal sealed class SyncService(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a
|
|
||||||
/// constraint violation surfacing as a 500.
|
|
||||||
/// </summary>
|
|
||||||
private static bool ValidateRelayFields(SyncPlaintextFields fields, out string error)
|
|
||||||
{
|
|
||||||
error = string.Empty;
|
|
||||||
|
|
||||||
if (fields.RelayEnabled)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
|
|
||||||
{
|
|
||||||
error = "Relay-enabled hosts require both a hostname and a port.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.Port is < 1 or > 65535)
|
|
||||||
{
|
|
||||||
error = "Port must be between 1 and 65535.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.Hostname is not null || fields.Port is not null)
|
|
||||||
{
|
|
||||||
error = "A hostname or port may only be supplied when relay is enabled for the host.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<SyncPushResult> RecordAsync(
|
private async Task<SyncPushResult> RecordAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
|
IItemKind kind,
|
||||||
Guid actorUserId,
|
Guid actorUserId,
|
||||||
SyncPushOperation operation,
|
SyncPushOperation operation,
|
||||||
SshHost host,
|
IVaultItem item,
|
||||||
ChangeOperation changeOperation,
|
ChangeOperation changeOperation,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var change = new VaultChange
|
var change = new VaultChange
|
||||||
{
|
{
|
||||||
VaultId = vault.Id,
|
VaultId = vault.Id,
|
||||||
EntityType = ChangeEntityType.SshHost,
|
EntityType = kind.ChangeType,
|
||||||
EntityId = host.Id,
|
EntityId = item.Id,
|
||||||
Operation = changeOperation,
|
Operation = changeOperation,
|
||||||
Revision = host.Version,
|
Revision = item.Version,
|
||||||
ActorUserId = actorUserId,
|
ActorUserId = actorUserId,
|
||||||
OccurredAtUtc = clock.GetUtcNow(),
|
OccurredAtUtc = clock.GetUtcNow(),
|
||||||
};
|
};
|
||||||
@@ -504,14 +469,14 @@ internal sealed class SyncService(
|
|||||||
// database on insert.
|
// database on insert.
|
||||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
host.ChangeSequence = change.Sequence;
|
item.ChangeSequence = change.Sequence;
|
||||||
|
|
||||||
database.SyncOperationReceipts.Add(new SyncOperationReceipt
|
database.SyncOperationReceipts.Add(new SyncOperationReceipt
|
||||||
{
|
{
|
||||||
OperationId = operation.OperationId,
|
OperationId = operation.OperationId,
|
||||||
VaultId = vault.Id,
|
VaultId = vault.Id,
|
||||||
AppliedChangeSequence = change.Sequence,
|
AppliedChangeSequence = change.Sequence,
|
||||||
ResultVersion = host.Version,
|
ResultVersion = item.Version,
|
||||||
CreatedAtUtc = clock.GetUtcNow(),
|
CreatedAtUtc = clock.GetUtcNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -520,7 +485,7 @@ internal sealed class SyncService(
|
|||||||
return new SyncPushResult(
|
return new SyncPushResult(
|
||||||
operation.OperationId,
|
operation.OperationId,
|
||||||
SyncOperationStatus.Applied,
|
SyncOperationStatus.Applied,
|
||||||
host.Version,
|
item.Version,
|
||||||
change.Sequence,
|
change.Sequence,
|
||||||
ServerEntity: null,
|
ServerEntity: null,
|
||||||
Detail: null);
|
Detail: null);
|
||||||
@@ -528,8 +493,9 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
private async Task<SyncPushResult> ConflictAsync(
|
private async Task<SyncPushResult> ConflictAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
|
IItemKind kind,
|
||||||
SyncPushOperation operation,
|
SyncPushOperation operation,
|
||||||
SshHost? existing,
|
IVaultItem? existing,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// The server cannot merge ciphertext, so it hands back its current state and the client
|
// 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,
|
Sequence = existing.ChangeSequence,
|
||||||
VaultId = existing.VaultId,
|
VaultId = existing.VaultId,
|
||||||
EntityType = ChangeEntityType.SshHost,
|
EntityType = kind.ChangeType,
|
||||||
EntityId = existing.Id,
|
EntityId = existing.Id,
|
||||||
Operation = existing.DeletedAtUtc is null
|
Operation = existing.DeletedAtUtc is null
|
||||||
? ChangeOperation.Upsert
|
? ChangeOperation.Upsert
|
||||||
@@ -569,64 +535,79 @@ internal sealed class SyncService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
List<VaultChange> changes,
|
List<VaultChange> changes,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var hostIds = changes
|
var rows = new Dictionary<(ChangeEntityType Type, Guid Id), IVaultItem>();
|
||||||
.Where(c => c.EntityType == ChangeEntityType.SshHost)
|
var kinds = new Dictionary<ChangeEntityType, IItemKind>();
|
||||||
.Select(c => c.EntityId)
|
|
||||||
.Distinct()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var hosts = hostIds.Length == 0
|
foreach (var group in changes.GroupBy(change => change.EntityType))
|
||||||
? []
|
{
|
||||||
: await database.Hosts
|
if (KindForChange(group.Key) is not { } kind)
|
||||||
.Where(h => h.VaultId == vault.Id && hostIds.Contains(h.Id))
|
{
|
||||||
.ToDictionaryAsync(h => h.Id, cancellationToken)
|
// 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);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var result = new List<Contracts.SyncChange>(changes.Count);
|
foreach (var (id, item) in loaded)
|
||||||
|
|
||||||
foreach (var change in changes)
|
|
||||||
{
|
{
|
||||||
hosts.TryGetValue(change.EntityId, out var host);
|
rows[(group.Key, id)] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A delete carries no payload: there is nothing left to decrypt, and shipping the
|
return [.. changes.Select(change => Describe(
|
||||||
// pre-delete ciphertext would undermine the point of the tombstone.
|
change,
|
||||||
var isDelete = change.Operation == ChangeOperation.Delete
|
kinds.GetValueOrDefault(change.EntityType),
|
||||||
|| host?.DeletedAtUtc is not null;
|
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||||
|
}
|
||||||
|
|
||||||
result.Add(new Contracts.SyncChange(
|
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
||||||
EntityType: SyncEntityType.Host,
|
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,
|
EntityId: change.EntityId,
|
||||||
Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert,
|
Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert,
|
||||||
Version: change.Revision,
|
Version: change.Revision,
|
||||||
ChangeSequence: change.Sequence,
|
ChangeSequence: change.Sequence,
|
||||||
Payload: isDelete || host is null
|
Payload: opaque
|
||||||
? null
|
? null
|
||||||
: new EncryptedPayload(
|
: new EncryptedPayload(
|
||||||
host.Payload,
|
item!.Payload,
|
||||||
// Non-null for every row a push can create: ValidatePayload refuses an
|
// Non-null for every row a push can create: ValidatePayload refuses an operation
|
||||||
// operation without them. The columns stay nullable because they are also
|
// without them. The columns stay nullable because they are also the seam for M5's
|
||||||
// the seam for M5's per-item grants.
|
// per-item grants.
|
||||||
host.DataKeyWrap ?? [],
|
item.DataKeyWrap ?? [],
|
||||||
host.ContentKeyId ?? Guid.Empty,
|
item.ContentKeyId ?? Guid.Empty,
|
||||||
(uint)host.KeyGeneration,
|
(uint)item.KeyGeneration,
|
||||||
(byte)host.PayloadAadVersion),
|
(byte)item.PayloadAadVersion),
|
||||||
PlaintextFields: isDelete || host is null
|
PlaintextFields: opaque || kind is null ? null : kind.Hydrate(item!),
|
||||||
? null
|
UpdatedAt: change.OccurredAtUtc);
|
||||||
: new SyncPlaintextFields(
|
|
||||||
RelayEnabled: host.RelayEnabled,
|
|
||||||
Hostname: host.Hostname,
|
|
||||||
Port: host.Port,
|
|
||||||
GroupId: host.GroupId),
|
|
||||||
UpdatedAt: change.OccurredAtUtc));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
|
||||||
}
|
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
||||||
|
|
||||||
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
||||||
await database.VaultChanges
|
await database.VaultChanges
|
||||||
@@ -645,5 +626,14 @@ internal sealed class SyncService(
|
|||||||
ServerEntity: null,
|
ServerEntity: null,
|
||||||
Detail: detail);
|
Detail: detail);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The two enums are numerically aligned by hand — and they do not even use the same member names for
|
||||||
|
/// the same value, <c>Host</c> against <c>SshHost</c> — so nothing in the type system defends this cast.
|
||||||
|
/// <c>EntityTypeAlignmentTests</c> 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.
|
||||||
|
/// </remarks>
|
||||||
private static ChangeEntityType ToDomain(SyncEntityType type) => (ChangeEntityType)(int)type;
|
private static ChangeEntityType ToDomain(SyncEntityType type) => (ChangeEntityType)(int)type;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ToDomain" />
|
||||||
|
private static SyncEntityType ToWire(ChangeEntityType type) => (SyncEntityType)(int)type;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ namespace DodoSSH.Domain;
|
|||||||
/// constraint enforces the pairing so it cannot drift. See ADR 0004.
|
/// constraint enforces the pairing so it cannot drift. See ADR 0004.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class SshHost
|
public sealed class SshHost : IVaultItem
|
||||||
{
|
{
|
||||||
/// <summary>Primary key. UUIDv7, generated by the client so items can be created offline.</summary>
|
/// <summary>Primary key. UUIDv7, generated by the client so items can be created offline.</summary>
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@@ -87,3 +87,84 @@ public sealed class SshHost
|
|||||||
/// <summary>Who last modified it.</summary>
|
/// <summary>Who last modified it.</summary>
|
||||||
public Guid UpdatedByUserId { get; set; }
|
public Guid UpdatedByUserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An SSH key pair, held as ciphertext.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The private key, its passphrase, its label and its comment are all inside <see cref="Payload"/>. 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Notably absent: the relay trio. A key is not something the server dials, so the plaintext concession
|
||||||
|
/// ADR 0004 makes for <see cref="SshHost"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="PublicKeyFingerprint"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VaultSshKey : IVaultItem
|
||||||
|
{
|
||||||
|
/// <summary>Primary key. UUIDv7, generated by the client so keys can be created offline.</summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Owning vault.</summary>
|
||||||
|
public Guid VaultId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Owning vault.</summary>
|
||||||
|
public Vault? Vault { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The encrypted key material: a DSH1 envelope. Opaque to the server.</summary>
|
||||||
|
public byte[] Payload { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
|
||||||
|
public byte[]? DataKeyWrap { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
|
||||||
|
public Guid? ContentKeyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Vault key generation this payload was encrypted under.</summary>
|
||||||
|
public int KeyGeneration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
|
||||||
|
public short PayloadAadVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenSSH-style <c>SHA256:base64</c> fingerprint of the public half, when a client publishes it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <c>authorized_keys</c>, so it is stored only
|
||||||
|
/// when a feature needs it rather than because it is harmless.
|
||||||
|
/// </remarks>
|
||||||
|
public string? PublicKeyFingerprint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
|
||||||
|
public long ChangeSequence { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Creation timestamp.</summary>
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Last modification timestamp.</summary>
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Soft-delete marker; a tombstone, so an offline client learns the key went away.</summary>
|
||||||
|
public DateTimeOffset? DeletedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Who created it.</summary>
|
||||||
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Who last modified it.</summary>
|
||||||
|
public Guid UpdatedByUserId { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
namespace DodoSSH.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shape every encrypted vault item shares.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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 <c>host</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Note what is <em>not</em> here. <c>xmin</c> is never exposed, because it is not stable across
|
||||||
|
/// <c>VACUUM FREEZE</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IVaultItem
|
||||||
|
{
|
||||||
|
/// <summary>Primary key. UUIDv7, generated by the client so items can be created offline.</summary>
|
||||||
|
Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Owning vault.</summary>
|
||||||
|
Guid VaultId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The encrypted item: a DSH1 envelope. Opaque to the server.</summary>
|
||||||
|
byte[] Payload { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
|
||||||
|
byte[]? DataKeyWrap { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
|
||||||
|
Guid? ContentKeyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Vault key generation this payload was encrypted under.</summary>
|
||||||
|
int KeyGeneration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
|
||||||
|
short PayloadAadVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
|
||||||
|
int Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
|
||||||
|
long ChangeSequence { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Creation timestamp.</summary>
|
||||||
|
DateTimeOffset CreatedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Last modification timestamp.</summary>
|
||||||
|
DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Soft-delete marker. Deletes are tombstones so an offline client can learn of them.</summary>
|
||||||
|
DateTimeOffset? DeletedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Who created it.</summary>
|
||||||
|
Guid CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Who last modified it.</summary>
|
||||||
|
Guid UpdatedByUserId { get; set; }
|
||||||
|
}
|
||||||
@@ -49,6 +49,43 @@ public sealed class HostConfiguration : IEntityTypeConfiguration<SshHost>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps <see cref="VaultSshKey"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Mirrors <see cref="HostConfiguration"/> 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SshKeyConfiguration : IEntityTypeConfiguration<VaultSshKey>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(EntityTypeBuilder<VaultSshKey> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
||||||
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
|||||||
/// <summary>SSH hosts.</summary>
|
/// <summary>SSH hosts.</summary>
|
||||||
public DbSet<SshHost> Hosts => Set<SshHost>();
|
public DbSet<SshHost> Hosts => Set<SshHost>();
|
||||||
|
|
||||||
|
/// <summary>SSH key pairs, held as ciphertext.</summary>
|
||||||
|
public DbSet<VaultSshKey> SshKeys => Set<VaultSshKey>();
|
||||||
|
|
||||||
/// <summary>The per-vault change log that delta sync reads.</summary>
|
/// <summary>The per-vault change log that delta sync reads.</summary>
|
||||||
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
||||||
|
|
||||||
|
|||||||
+1069
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSshKeyItem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ssh_key",
|
||||||
|
schema: "dodo",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
payload = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
|
||||||
|
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
key_generation = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
|
||||||
|
public_key_fingerprint = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
version = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
change_sequence = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
xmin = table.Column<uint>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ssh_key",
|
||||||
|
schema: "dodo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -826,6 +826,92 @@ namespace DodoSSH.Infrastructure.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<long>("ChangeSequence")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("change_sequence");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ContentKeyId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("content_key_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("created_by_user_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("DataKeyWrap")
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("data_key_wrap");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at_utc");
|
||||||
|
|
||||||
|
b.Property<int>("KeyGeneration")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<short>("PayloadAadVersion")
|
||||||
|
.HasColumnType("smallint")
|
||||||
|
.HasColumnName("payload_aad_version");
|
||||||
|
|
||||||
|
b.Property<string>("PublicKeyFingerprint")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("public_key_fingerprint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("UpdatedByUserId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("updated_by_user_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("version");
|
||||||
|
|
||||||
|
b.Property<uint>("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 =>
|
modelBuilder.Entity("DodoSSH.Domain.Device", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DodoSSH.Domain.UserAccount", "User")
|
b.HasOne("DodoSSH.Domain.UserAccount", "User")
|
||||||
@@ -942,6 +1028,18 @@ namespace DodoSSH.Infrastructure.Migrations
|
|||||||
b.Navigation("Vault");
|
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 =>
|
modelBuilder.Entity("DodoSSH.Domain.Team", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Memberships");
|
b.Navigation("Memberships");
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Domain;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two entity-type enums have to agree, and nothing but this makes them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>SyncService</c> converts between the wire's <see cref="SyncEntityType"/> and the change log's
|
||||||
|
/// <see cref="ChangeEntityType"/> with a raw <c>(ChangeEntityType)(int)</c> 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: <c>Host = 1</c> against <c>SshHost = 1</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Numeric alignment only. The two lists are deliberately <em>not</em> aligned with
|
||||||
|
/// <c>CryptoSpec.AadResourceType</c>, which also carries None/User/Device/Vault and therefore numbers the
|
||||||
|
/// same item types differently — asserting three-way equality would be asserting something false.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class EntityTypeAlignmentTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryWireEntityType_HasAChangeLogTypeWithTheSameValue()
|
||||||
|
{
|
||||||
|
var changeValues = Enum.GetValues<ChangeEntityType>().Select(value => (int)value).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var wire in Enum.GetValues<SyncEntityType>())
|
||||||
|
{
|
||||||
|
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<SyncEntityType>().Select(value => (int)value).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var change in Enum.GetValues<ChangeEntityType>())
|
||||||
|
{
|
||||||
|
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<SyncEntityType>().Length.ShouldBe(Enum.GetValues<ChangeEntityType>().Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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
|
||||||
|
/// <c>Host</c> has no <c>Host</c> on the other side, and renumber to "fix" it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TheDifferentlyNamedPair_IsTheOneThatMatches()
|
||||||
|
{
|
||||||
|
((int)SyncEntityType.Host).ShouldBe((int)ChangeEntityType.SshHost);
|
||||||
|
((int)SyncEntityType.SshKey).ShouldBe((int)ChangeEntityType.SshKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -608,6 +608,145 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
// Just-in-time provisioning is covered by IdentityEndpointTests, against /me — the endpoint a
|
// Just-in-time provisioning is covered by IdentityEndpointTests, against /me — the endpoint a
|
||||||
// client actually calls first, and the only one reachable before enrollment.
|
// 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<SyncPushResponse>();
|
||||||
|
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<SyncPullResponse>();
|
||||||
|
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<SyncPushResponse>()).Results.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
result.Status.ShouldBe(SyncOperationStatus.Invalid);
|
||||||
|
result.Detail.ShouldContain("no relay target");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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<SyncPushResponse>()).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<SyncPullResponse>();
|
||||||
|
|
||||||
|
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<SyncPushResponse>()).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<SyncPullResponse>();
|
||||||
|
var last = page.Changes[^1];
|
||||||
|
|
||||||
|
last.Operation.ShouldBe(SyncOperation.Delete);
|
||||||
|
last.Payload.ShouldBeNull("a tombstone must not ship the key material it replaced");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
private static string PullUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/pull";
|
private static string PullUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/pull";
|
||||||
@@ -633,6 +772,20 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
Payload(envelope),
|
Payload(envelope),
|
||||||
new SyncPlaintextFields());
|
new SyncPlaintextFields());
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>PlaintextFields: null</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
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() =>
|
private static SyncPushRequest NewCreateBatch() =>
|
||||||
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
|
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user