diff --git a/docs/adding-hosts-on-the-phone.md b/docs/adding-hosts-on-the-phone.md index f472e82..cb057dd 100644 --- a/docs/adding-hosts-on-the-phone.md +++ b/docs/adding-hosts-on-the-phone.md @@ -4,7 +4,24 @@ The phone can read a keychain and connect through it. It cannot put anything in plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the ordered work, and the traps that are already known. -> **Status: planned, not started.** Nothing below is built. The only thing in the tree is this file. +> **Status: steps 1–3 built, 4–6 not started.** The domain is done and the phone has not been touched. Each +> built step compiles with the whole suite green, which is the rule the ordering below sets. +> +> | Step | State | Notes | +> | --- | --- | --- | +> | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. | +> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. | +> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. | +> | 4. The shared view model | Not started | The five port call sites already route through the resolver; the rest of the list below does not. | +> | 5. The phone | Not started | | +> | 6. Tests and false prose | Partly done as it went | The guards steps 1–3 tripped are fixed. `docs/design-import-gaps.md` and the three phone files are untouched. | +> +> **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not +> three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer, +> and two nullable ids express three of those. Naming neither id now means *inherit*, so +> `HostSecret.AsksForPassword` was added to say "a typed password, even under a group that lends a key" out +> loud. Nothing stored changed meaning — no group could lend a binding before this build, so every existing +> host resolves exactly as it did. ## What was asked for @@ -69,18 +86,19 @@ false prose and must be rewritten to say what replaced the argument, not deleted ## What the payload becomes ``` -HostSecret HostGroupSecret - Label Label - Hostname ParentId ← new, optional - Port int? — null inherits DefaultPort ← new - Username null inherits DefaultUsername ← new - Notes DefaultSshKeyId ← new - JumpHostIds DefaultCredentialId ← new +HostSecret HostGroupSecret + Label Label + Hostname ParentId ← new, optional + Port int? — null inherits DefaultPort ← new + Username null inherits, "" none DefaultUsername ← new + Notes DefaultSshKeyId ← new + JumpHostIds DefaultCredentialId ← new Options - SshKeyId null inherits TagSecret - CredentialId null inherits Label - GroupId null = ungrouped - TagIds ← new + SshKeyId null inherits TagSecret + CredentialId null inherits Label + AsksForPassword ← new, true only + GroupId null = ungrouped + TagIds ← new RelayEnabled ``` @@ -92,10 +110,18 @@ after the chain runs out. `SshKeyId` and `CredentialId` both null currently *means* "ask for a password each time" — a decision, not an absence. `AuthenticationChoice`'s own remark argues at length that the two must never be conflated. -Inheritance adds a third state, so the picker needs an explicit **"Inherit from group"** entry beside -**"Password (ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: -null means "no username" today and is refused at connect; it has to come to mean "inherit", with "no username" -still reachable and still refused. +Inheritance adds a state, so the picker needs an explicit **"Inherit from group"** entry beside **"Password +(ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: null means +"no username" today and is refused at connect; it has to come to mean "inherit", with "no username" still +reachable and still refused. + +**Built as four states, not three, because two nullable ids only carry three.** Key, credential, typed +password, inherit — and naming neither id was the third and is now the fourth. `HostSecret.AsksForPassword` +carries the difference: null is "not stated", which walks the chain and lands on a typed password if the +chain lends nothing, and `true` is "a typed password, even under a group that lends a key". Only `true` is +ever written, and a decoded `false` folds back to null, so a host that never touched the field encodes +exactly as it did before it existed. `Username` needed no field — an empty string is "no username" and null +is "inherit". `Port` needed none either: there is no "explicitly no port", only 22 at the end of the chain. Mutual exclusion moves with it. `HostSecret.TryValidate` enforces "a key or a credential, never both" per record; a host naming a credential under a group naming a key is two individually valid records that resolve to diff --git a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs index 44bc757..867f83f 100644 --- a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs +++ b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs @@ -71,7 +71,7 @@ internal static class ItemKinds new[] { (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(), - new HostGroupKind(), new SnippetKind(), + new HostGroupKind(), new TagKind(), new SnippetKind(), new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(), }.ToDictionary(kind => kind.WireType); @@ -557,13 +557,17 @@ internal sealed class HostGroupKind : IItemKind } /// - /// Refuses every plaintext field there is, including the one named after this type. + /// Refuses every plaintext field there is, including the two that would describe this type's own place + /// in a tree. /// /// - /// A GroupId on a group would be a parent pointer, and groups are flat — see - /// for why nesting merged by a scalar three-way merge can produce a cycle - /// nothing is able to repair. Refusing it here means a client that grows a tree cannot store one by - /// accident. + /// Groups nest, and the pointer that nests them is still refused here. The refusal used to mean + /// "there is no such thing as a parent"; it now means "the parent is not the server's to hold". A + /// ParentId or GroupId column on this table would let the operator reconstruct the shape of + /// every user's estate — how many groupings, how deep, which under which — which is precisely the + /// disclosure ADR 0004 refuses everywhere except the one address the relay cannot dial without. The + /// parent travels inside the envelope like the name beside it, and the client is the only thing that can + /// read either. /// /// public bool ValidateFields(SyncPlaintextFields fields, out string error) @@ -580,7 +584,7 @@ internal sealed class HostGroupKind : IItemKind if (fields.GroupId is not null || fields.ParentId is not null) { - error = "Host groups are flat, and a group's name is inside its payload."; + error = "A host group's name and its place in the tree are inside its payload."; return false; } @@ -608,6 +612,108 @@ internal sealed class HostGroupKind : IItemKind public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } +/// Tags: an envelope and nothing else. +/// +/// 's answer to the same question, at higher stakes. A group name says how one +/// user files their machines; a tag name says what the machines are, and one tag is meant to span twenty of +/// them — so a plaintext label here would hand the operator a labelled map of every estate on the server, to +/// sort a list nothing server-side draws. +/// +internal sealed class TagKind : IItemKind +{ + /// + public SyncEntityType WireType => SyncEntityType.Tag; + + /// + public ChangeEntityType ChangeType => ChangeEntityType.Tag; + + /// + public async Task FindAsync( + DodoDbContext database, + Guid id, + CancellationToken cancellationToken) => + await database.Tags.SingleOrDefaultAsync(t => t.Id == id, cancellationToken) + .ConfigureAwait(false); + + /// + public async Task> LoadAsync( + DodoDbContext database, + Guid vaultId, + Guid[] ids, + CancellationToken cancellationToken) + { + var rows = await database.Tags + .Where(t => t.VaultId == vaultId && ids.Contains(t.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); + } + + /// + public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) + { + var tag = new VaultTag { Id = id, VaultId = vaultId }; + + database.Tags.Add(tag); + + return tag; + } + + /// + /// Refuses every plaintext field there is, including the one a join table would have reached for. + /// + /// + /// RelatedId matters more here than for any other kind. It is the obvious place to put "the + /// host this tag is on", which is exactly what SyncEntityType.HostTag reserves a slot for and what + /// this design decided not to build — membership is a set inside each host's payload. A future client + /// reaching for the field would be storing the host-to-tag graph in the clear, one row at a time, which + /// is the aggregation this kind exists to refuse. SyncPlaintextFields is frozen so the field + /// cannot be removed; refusing it is the only place the decision can be enforced. + /// + /// + 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 = "A tag is not something the server dials."; + return false; + } + + if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null) + { + error = "A tag's name is inside its payload, and which hosts wear it is inside theirs."; + return false; + } + + if (fields.PublicKeyFingerprint is not null) + { + error = "A tag has no public key."; + return false; + } + + return true; + } + + /// Nothing to copy: this type has no plaintext columns to copy anything into. + /// + public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) + { + } + + /// + public void ClearFieldsOnDelete(IVaultItem item) + { + } + + /// + public SyncPlaintextFields? Hydrate(IVaultItem item) => null; +} + /// Snippets: an envelope and nothing else. /// /// A label column here would sort a list this server never draws, and the commands beside that label describe diff --git a/src/DodoSSH.Client.Domain/HostGroupSecret.cs b/src/DodoSSH.Client.Domain/HostGroupSecret.cs index 0dff237..be58717 100644 --- a/src/DodoSSH.Client.Domain/HostGroupSecret.cs +++ b/src/DodoSSH.Client.Domain/HostGroupSecret.cs @@ -3,24 +3,36 @@ using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// -/// A folder hosts can be filed under, decrypted. +/// A folder hosts can be filed under, decrypted, and the defaults they inherit from it. /// /// /// -/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group -/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it -/// sits in a tree, what colour it is — was considered and left out, each for its own reason. +/// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a +/// so headings nest, and four Default fields a host under this group falls +/// back to when it leaves the matching field unset. /// /// /// No member list. Membership is a on each host, so filing two /// different hosts into one group on two machines is two writes to two items. Held here it would be two -/// writes to one item, and has no set merge — the collision would resolve by one -/// side winning outright and the other host silently leaving the group it was just put in. +/// writes to one item, and while could now resolve that key by key, the +/// pointer on the host is still the better place: it is one write to the item the user just edited, and it +/// cannot disagree with itself about which group a host is in. /// /// -/// No parent. Groups are flat. Two clients can each re-parent A under B and B under A while offline, -/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot -/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path. +/// Groups nest, and the cycle is contained rather than prevented. This is a reversal, and the +/// argument it reverses was real: two clients can each re-parent A under B and B under A while offline, a +/// scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot even see, +/// because it is inside the payload. What changed is that a merge is not the only place a cycle has to +/// survive. Inheritance means the chain is walked at connect time, so the answer had to be a walk that +/// terminates regardless — the resolver carries a visited set and stops at a repeat. A cycle therefore +/// degrades to a group that reads as a root: flat headings, defaults unresolved past that point, and +/// clearing the parent in the editor is the repair. Given a walk that already had to be cycle-safe, +/// refusing to nest bought nothing. +/// +/// +/// The editor additionally refuses a parent that is already a descendant, which stops a cycle being made +/// on this machine. That is a convenience, not the guarantee — the guarantee is the visited set, because a +/// cycle assembled from two offline edits was never offered to an editor at all. /// /// public sealed record HostGroupSecret : IVaultSecret @@ -28,11 +40,85 @@ public sealed record HostGroupSecret : IVaultSecret /// What the group is called. The only name it has anywhere. public required string Label { get; init; } + /// + /// The group this one sits under, or null for a root. + /// + /// + /// + /// Points upwards for the same reason does: re-parenting two groups + /// under one parent on two machines is then two writes to two items rather than two writes to one. + /// + /// + /// The reference may dangle, and a dangling one is a root — the group whose parent was deleted on + /// another machine appears at the top level rather than disappearing. Same handling as a host whose group + /// is gone, and for the same reason: preventing it would mean one delete rewriting every item that named + /// the deleted thing. + /// + /// + /// Inside the payload. SyncPlaintextFields has a ParentId and the server refuses to accept + /// one, deliberately: what a plaintext parent hands over is the shape of the user's estate, which is the + /// same disclosure a plaintext GroupId would have been. See ADR 0004. + /// + /// + public Guid? ParentId { get; init; } + + /// + /// The TCP port hosts in this group use when they do not pin one, or null for no default. + /// + /// + /// Inherited, not copied. A host created here is not stamped with this value; it is left unset and reads + /// through at connect time, so changing this changes every host that never overrode it. Copying at + /// creation was the alternative and it makes a group a one-shot template — every host ever created under + /// it stays pinned to whatever the default happened to be that day. + /// + public int? DefaultPort { get; init; } + + /// + /// The login user hosts in this group use when they do not pin one, or null for no default. + /// + /// + public string? DefaultUsername { get; init; } + + /// + /// The vault SSH key hosts in this group authenticate with when they bind nothing, or null for no + /// default. + /// + /// + /// + /// Same dangling-reference story as , and the same refusal to fall back + /// to a password when it cannot be resolved. One extra consequence: a key named only here is named by no + /// host at all, so anything counting the hosts bound to a key has to walk groups or it will report a key + /// as unused and then refuse every host under this group at connect time. + /// + /// + /// Mutually exclusive with on this record, exactly as the two + /// are on a host. That is necessary and not sufficient: a host naming a credential under a group naming a + /// key is two individually valid records, so the resolver enforces the same exclusion again across the + /// chain, host over group, and never returns both. + /// + /// + public Guid? DefaultSshKeyId { get; init; } + + /// + /// The vault credential hosts in this group authenticate with when they bind nothing, or null for no + /// default. + /// + /// + public Guid? DefaultCredentialId { get; init; } + /// Whether this is storable, and why not if it is not. /// - /// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is + /// + /// A blank name is refused rather than defaulted. A group is a heading, so a nameless one is /// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they /// cannot tell apart. + /// + /// + /// A group's own id is not knowable here, so "a group is not its own parent" cannot be checked at + /// this layer: the id belongs to the item, and this record is only the payload inside it. A self-parent + /// is therefore caught twice further out — refused by the editor, and survived by the resolver's visited + /// set, which is the same machinery that has to contain a longer cycle anyway. + /// /// public bool TryValidate([NotNullWhen(false)] out string? reason) { @@ -42,6 +128,40 @@ public sealed record HostGroupSecret : IVaultSecret return false; } + if (ParentId == Guid.Empty) + { + // An empty id is not "no parent" — that is null. It is a reference that can never resolve, and a + // group holding one would read as a root while claiming to be nested. + reason = "A parent reference cannot be an empty id; use no parent instead."; + return false; + } + + // Null is "no default port" and passes this comparison, which is the wanted behaviour and is + // silent about it: `is < 1 or > 65535` is false for a null int?. + if (DefaultPort is < 1 or > 65535) + { + reason = $"A default port must be between 1 and 65535, not {DefaultPort}."; + return false; + } + + if (DefaultSshKeyId == Guid.Empty) + { + reason = "An SSH key reference cannot be an empty id; use no default key instead."; + return false; + } + + if (DefaultCredentialId == Guid.Empty) + { + reason = "A credential reference cannot be an empty id; use no default credential instead."; + return false; + } + + if (DefaultSshKeyId is not null && DefaultCredentialId is not null) + { + reason = "A group defaults to a key or to a credential, not both."; + return false; + } + reason = null; return true; } diff --git a/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs b/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs index a23f253..267d63c 100644 --- a/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs +++ b/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs @@ -17,15 +17,29 @@ public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVe /// Encodes and decodes the plaintext inside a group item's encrypted payload. /// /// -/// Mirrors , for the same reasons and with the same guarantees. One field -/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema -/// version, which is what lets a later build add a field without every older client silently dropping it on -/// the next edit. See . +/// Mirrors , for the same reasons and with the same guarantees. What the +/// JSON envelope buys is a schema version, which is what lets a later build add a field without every older +/// client silently dropping it on the next edit — and this is the build that spent it, five fields at once. +/// See . /// public static class HostGroupSecretCodec { - /// The schema version this build writes. - public const int CurrentSchemaVersion = 1; + /// The first version, and the one a group with no newer field is still written at. + public const int BaseSchemaVersion = 1; + + /// + /// The version that introduced and the four defaults. + /// + /// + /// One constant for five fields, because they arrive in the same build: no client exists that can read + /// some of them and not the others, so a version per field would draw a distinction nothing can observe. + /// is still written as a maximum, which is what has to stay true when the + /// sixth field arrives on its own. + /// + public const int ParentAndDefaultsSchemaVersion = 2; + + /// The highest schema version this build can write. + public const int CurrentSchemaVersion = ParentAndDefaultsSchemaVersion; /// Serialises a group to the bytes that get sealed. /// The group is not valid for storage. @@ -40,14 +54,55 @@ public static class HostGroupSecretCodec var document = new HostGroupPayloadDocument { - SchemaVersion = CurrentSchemaVersion, + SchemaVersion = SchemaVersionFor(group), Label = group.Label, + ParentId = group.ParentId, + DefaultPort = group.DefaultPort, + DefaultUsername = group.DefaultUsername, + DefaultSshKeyId = group.DefaultSshKeyId, + DefaultCredentialId = group.DefaultCredentialId, }; return JsonSerializer.SerializeToUtf8Bytes( document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument); } + /// + /// The lowest schema version that can represent this group without losing anything. + /// + /// + /// + /// Not simply , which is what this codec used to stamp + /// unconditionally — harmless while there was one field and one version, and not harmless from here on. + /// The version is what makes an older client treat an + /// item as read-only, so stamping the newest one regardless would mean that upgrading a single machine + /// and then renaming any group made that group uneditable on every machine still on the old + /// build. Emitting the lowest version that loses nothing confines that cost to the groups which actually + /// nest or actually carry a default. + /// + /// + /// It also means a group carrying none of the new fields encodes byte for byte as it did before they + /// existed, so adding them did not make every group in every vault look like a change to the sync + /// engine. has the same rule and it is the same argument; see + /// SchemaVersionFor there for why it is a maximum over the fields present rather than a ladder. + /// + /// + private static int SchemaVersionFor(HostGroupSecret group) + { + var version = BaseSchemaVersion; + + if (group.ParentId is not null + || group.DefaultPort is not null + || group.DefaultUsername is not null + || group.DefaultSshKeyId is not null + || group.DefaultCredentialId is not null) + { + version = Math.Max(version, ParentAndDefaultsSchemaVersion); + } + + return version; + } + /// Parses a decrypted payload. /// public static bool TryDecode( @@ -72,7 +127,15 @@ public static class HostGroupSecretCodec return false; } - var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty }; + var candidate = new HostGroupSecret + { + Label = parsed.Label ?? string.Empty, + ParentId = parsed.ParentId, + DefaultPort = parsed.DefaultPort, + DefaultUsername = parsed.DefaultUsername, + DefaultSshKeyId = parsed.DefaultSshKeyId, + DefaultCredentialId = parsed.DefaultCredentialId, + }; if (!candidate.TryValidate(out _)) { @@ -91,6 +154,26 @@ internal sealed class HostGroupPayloadDocument public int SchemaVersion { get; set; } public string? Label { get; set; } + + /// + /// After , deliberately. Property order is the serialisation order, so appending + /// keeps the bytes for the one field that existed before these identical — and a null is omitted + /// entirely, which is what makes a flat group with no defaults encode exactly as it did before any of + /// this existed. + /// + public Guid? ParentId { get; set; } + + /// + public int? DefaultPort { get; set; } + + /// + public string? DefaultUsername { get; set; } + + /// + public Guid? DefaultSshKeyId { get; set; } + + /// + public Guid? DefaultCredentialId { get; set; } } [JsonSourceGenerationOptions( diff --git a/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs b/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs index 08a57be..d57e57f 100644 --- a/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs +++ b/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace DodoSSH.Client.Domain; /// The merged group, and everything that had to be overridden to produce it. @@ -16,14 +18,22 @@ public sealed record HostGroupMergeResult( /// /// /// -/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it -/// does not have to consider. Filing a host into a group does not write to the group, so two people -/// organising the same vault at the same time never collide here — the only way to reach this code is for two -/// people to rename the same group differently, which is a real disagreement and gets a conflict notice. +/// Six scalars, no collections, which keeps this the simplest item merge in the client — and the +/// interesting thing about it is still what it does not have to consider. Filing a host into a +/// group does not write to the group, so two people organising the same vault at the same time never +/// collide here. /// /// -/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name -/// differed" would leave the user unable to tell which of their two names survived. +/// The parent merges as a scalar, and that is what admits a cycle. Two clients re-parenting A under +/// B and B under A while offline each produce a locally sensible group, and there is nothing here that can +/// see the pair. Resolving it would mean a merge that reads the whole group list, which the merge does not +/// have and should not grow — this layer resolves one item against one item. The cycle is contained where +/// it is walked instead: see . +/// +/// +/// Nothing is redacted. The name is the one thing a group has, and a notice saying only that "the name +/// differed" would leave the user unable to tell which of their two names survived. The ids are shown for +/// the same reason they are on a host — an id names a vault item, it is not the secret inside it. /// /// public static class HostGroupSecretMerge @@ -43,21 +53,123 @@ public static class HostGroupSecretMerge var conflicts = new List(); - var merge = ThreeWayMerge.Scalar( - ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal); + var merged = new HostGroupSecret + { + Label = Field( + nameof(HostGroupSecret.Label), + ancestor.Label, + local.Label, + remote.Label, + conflicts, + static label => label, + StringComparer.Ordinal), + + ParentId = Field( + nameof(HostGroupSecret.ParentId), + ancestor.ParentId, + local.ParentId, + remote.ParentId, + conflicts, + static id => id?.ToString() ?? "no parent"), + }; + + return new HostGroupMergeResult( + WithDefaults(merged, ancestor, local, remote, conflicts), conflicts); + } + + /// + /// Merges the four values hosts under this group fall back to. + /// + /// + /// + /// Split out for length, and they do belong together: each is a value a host reads through to when it + /// leaves its own field unset, and each merges as a plain scalar. The two ids can additionally end up + /// dangling, exactly as a host's can — handled where the reference is used, not here. + /// + /// + /// The exclusion between the key and the credential is not re-checked here, and it does not need + /// to be: neither side can hold both, and a scalar merge takes one value per field from one side or the + /// other, so it cannot manufacture a pair that was not offered. What it can manufacture is a group + /// defaulting to a key beneath which a host names a credential, and no item merge can see that — it is + /// the resolver's to enforce. + /// + /// + private static HostGroupSecret WithDefaults( + HostGroupSecret merged, + HostGroupSecret ancestor, + HostGroupSecret local, + HostGroupSecret remote, + List conflicts) => + merged with + { + DefaultPort = Field( + nameof(HostGroupSecret.DefaultPort), + ancestor.DefaultPort, + local.DefaultPort, + remote.DefaultPort, + conflicts, + static port => port?.ToString(CultureInfo.InvariantCulture) ?? "no default port"), + + DefaultUsername = Field( + nameof(HostGroupSecret.DefaultUsername), + ancestor.DefaultUsername, + local.DefaultUsername, + remote.DefaultUsername, + conflicts, + static username => username ?? "no default user", + StringComparer.Ordinal), + + DefaultSshKeyId = Field( + nameof(HostGroupSecret.DefaultSshKeyId), + ancestor.DefaultSshKeyId, + local.DefaultSshKeyId, + remote.DefaultSshKeyId, + conflicts, + static id => id?.ToString() ?? "no default key"), + + DefaultCredentialId = Field( + nameof(HostGroupSecret.DefaultCredentialId), + ancestor.DefaultCredentialId, + local.DefaultCredentialId, + remote.DefaultCredentialId, + conflicts, + static id => id?.ToString() ?? "no default credential"), + }; + + /// + /// + /// The local side always loses a scalar clash — see — so the discarded side + /// is fixed here rather than derived from the outcome. + /// + /// + /// The formatter is handed the discarded value even when that value is null, and the null-forgiving + /// operator says why that is safe: a conflicted merge always has a discarded value, so a null here is a + /// nullable field whose discarded value was "unset" rather than a missing one. Every field on this + /// record but the name has a name for its absence, and short-circuiting on null would mean none of them + /// could report it. + /// + /// + private static T Field( + string name, + T ancestor, + T local, + T remote, + List conflicts, + Func format, + IEqualityComparer? comparer = null) + { + var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer); if (merge.IsConflicted) { - // The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is - // fixed here rather than derived from the outcome. conflicts.Add(new HostFieldConflict( - nameof(HostGroupSecret.Label), + name, MergeSide.Local, - merge.Value, - merge.Discarded, + format(merge.Value), + format(merge.Discarded!), DiscardedWasRemoval: false)); } - return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts); + return merge.Value; } } diff --git a/src/DodoSSH.Client.Domain/HostInheritance.cs b/src/DodoSSH.Client.Domain/HostInheritance.cs new file mode 100644 index 0000000..1f2a86b --- /dev/null +++ b/src/DodoSSH.Client.Domain/HostInheritance.cs @@ -0,0 +1,217 @@ +using System.Runtime.InteropServices; + +namespace DodoSSH.Client.Domain; + +/// Which of the three ways a resolved host authenticates. +public enum ResolvedBindingKind +{ + /// + /// A password typed at connect time. The terminal answer, reached either because nothing in the chain + /// bound anything or because the host said so outright. + /// + TypedPassword = 0, + + /// A vault SSH key. + SshKey = 1, + + /// A vault credential. + Credential = 2, +} + +/// +/// One resolved value, and the group it came from. +/// +/// The value's type. +/// What the connect path should use. +/// +/// The group that supplied it, or null when the host supplied it itself — which is also what the editor +/// needs, because a value the host owns is text in a box and a value it inherits is a placeholder behind an +/// empty one. +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct Inherited(T Value, Guid? FromGroupId) +{ + /// Whether this came from a group rather than from the host. + public bool IsInherited => FromGroupId is not null; +} + +/// +/// How a host authenticates once its group chain has been consulted. +/// +/// Which of the three ways. +/// +/// The key or credential item id, or null for a typed password. +/// +/// The group that supplied the binding, or null when the host did. +[StructLayout(LayoutKind.Auto)] +public readonly record struct ResolvedBinding( + ResolvedBindingKind Kind, + Guid? EntityId, + Guid? FromGroupId) +{ + /// A password typed at connect time, decided by the host itself. + public static ResolvedBinding TypedByTheHost { get; } = + new(ResolvedBindingKind.TypedPassword, null, null); + + /// Whether this came from a group rather than from the host. + public bool IsInherited => FromGroupId is not null; +} + +/// +/// Everything about a host that only makes sense once its groups have been read. +/// +/// The port to dial. +/// The user to log in as, or null when nothing supplied one. +/// How to authenticate. +public sealed record ResolvedHost( + Inherited Port, + Inherited Username, + ResolvedBinding Binding); + +/// +/// Resolves a host against the groups above it. +/// +/// +/// +/// A host may leave its port, its username and its binding unset, in which case each is taken from the +/// nearest group above it that states one — its own group, then that group's parent, and so on. Nothing is +/// copied at creation, so changing a group changes every host beneath it that never overrode the field. See +/// . +/// +/// +/// Every walk carries a visited set, and that is load-bearing rather than defensive. Two clients can +/// each re-parent A under B and B under A while offline; the merge resolves one item against one item and +/// cannot see the pair, and the server cannot see it either because the pointer is inside the payload. With +/// inheritance the chain is walked on the connect path, so an unguarded cycle is not an undrawable sidebar +/// — it is a shell that never opens. Stopping at the first repeat degrades a cycle to a group that reads as +/// a root: flat headings, defaults unresolved past that point, and clearing the parent in the editor is the +/// repair. +/// +/// +/// The key-or-credential exclusion is enforced here as well as on each record. A host naming a +/// credential under a group naming a key is two individually valid items, so per-record validation cannot +/// catch it. The nearest statement wins outright — host over group, nearer group over further — and this +/// never returns both. +/// +/// +/// Groups are handed in as a lookup rather than fetched, because this runs on the connect path and on every +/// row of a list. The caller already holds the decrypted group list; a resolver that went back to a +/// repository would decrypt the vault once per host drawn. +/// +/// +public static class HostInheritance +{ + /// + /// Resolves one host. + /// + /// The host, as stored. + /// + /// Every group in the vaults being read, by item id. A missing id is a group deleted on another machine, + /// and the walk stops there — the host keeps whatever it had already resolved and falls back for the + /// rest, rather than failing. + /// + public static ResolvedHost Resolve(HostSecret host, IReadOnlyDictionary groups) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(groups); + + Inherited? port = host.Port is { } pinned ? new Inherited(pinned, null) : null; + + // Note which null is which. `host.Username is null` means the host stated nothing and the walk + // continues; an empty string is a statement — "no username" — and stops it, so a host under a group + // can opt out of the group's user. See HostSecret.Username. + Inherited? username = host.Username is not null + ? new Inherited(host.Username, null) + : null; + + var binding = BindingOf(host); + + foreach (var (groupId, group) in Chain(host.GroupId, groups)) + { + port ??= group.DefaultPort is { } inheritedPort + ? new Inherited(inheritedPort, groupId) + : null; + + username ??= group.DefaultUsername is not null + ? new Inherited(group.DefaultUsername, groupId) + : null; + + binding ??= BindingOf(group, groupId); + + if (port is not null && username is not null && binding is not null) + { + break; + } + } + + return new ResolvedHost( + port ?? new Inherited(HostSecret.DefaultPort, null), + username ?? new Inherited(null, null), + binding ?? ResolvedBinding.TypedByTheHost); + } + + /// + /// The groups above a host, nearest first, stopping at a repeat. + /// + /// + /// Public because the editor walks the same chain to draw its placeholders and the sidebar walks it to + /// draw headings, and three separate walks would be three places to forget the visited set. Yields the + /// id beside the group, because a resolved value has to be able to say which group it came from. + /// + /// Where to start: a host's group, or a group's parent. + /// Every group in the vaults being read, by item id. + public static IEnumerable<(Guid Id, HostGroupSecret Group)> Chain( + Guid? groupId, + IReadOnlyDictionary groups) + { + ArgumentNullException.ThrowIfNull(groups); + + // Allocated per walk rather than shared, because these run concurrently on the connect path and on + // whatever thread a list is drawn from. + var visited = new HashSet(); + var current = groupId; + + while (current is { } id && visited.Add(id)) + { + if (!groups.TryGetValue(id, out var group)) + { + // A dangling id: the group was deleted on another machine. The host falls under the + // ungrouped heading and inherits nothing further, which is the same answer the interface + // gives for a group that is simply not there. + yield break; + } + + yield return (id, group); + + current = group.ParentId; + } + } + + /// + /// Null means "this record did not answer", which is what lets the caller keep walking. It is not the + /// same as , which is an answer — and conflating the two + /// is how a host deliberately put back on a typed password would silently pick up its group's key. + /// + private static ResolvedBinding? BindingOf(HostSecret host) => host switch + { + { SshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, null), + { CredentialId: { } credential } => + new ResolvedBinding(ResolvedBindingKind.Credential, credential, null), + { AsksForPassword: true } => ResolvedBinding.TypedByTheHost, + _ => null, + }; + + /// + /// A group has no equivalent of , so it either lends a binding + /// or says nothing. "Everything under here types its password" is not a thing a group can assert: it + /// would be indistinguishable from a group that simply has no default, and the two would resolve the + /// same way anyway once the chain ran out. + /// + private static ResolvedBinding? BindingOf(HostGroupSecret group, Guid groupId) => group switch + { + { DefaultSshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, groupId), + { DefaultCredentialId: { } credential } => + new ResolvedBinding(ResolvedBindingKind.Credential, credential, groupId), + _ => null, + }; +} diff --git a/src/DodoSSH.Client.Domain/HostSecret.cs b/src/DodoSSH.Client.Domain/HostSecret.cs index 1d4c620..8b8c438 100644 --- a/src/DodoSSH.Client.Domain/HostSecret.cs +++ b/src/DodoSSH.Client.Domain/HostSecret.cs @@ -35,10 +35,37 @@ public sealed record HostSecret : IVaultSecret /// Hostname or address to connect to. public required string Hostname { get; init; } - /// TCP port. - public int Port { get; init; } = DefaultPort; + /// + /// TCP port, or null to take the group's. + /// + /// + /// + /// Nullable, and it had to become so: an defaulting to 22 has no way to say "I have no + /// port of my own". stays, as the last fallback after the group chain runs out + /// — so a host that inherits nothing still dials 22, exactly as it always did. + /// + /// + /// This is the field with the widest blast radius in the vault, and the sharp edge is on the way + /// out, not in. A host with a null port is written at a schema version older clients do not know, + /// and they will not merely find it read-only — they cannot decode it at all, because the property is + /// omitted, their int Port reads 0 and refuses it. That cost is + /// confined to hosts which actually inherit, because the codec stamps the lowest version that loses + /// nothing; the alternative, writing 22 into every host, would make inheritance a lie the moment a + /// group's default changed. See . + /// + /// + public int? Port { get; init; } - /// Login user, when the host pins one. + /// + /// Login user: pinned when set, no username when empty, the group's when null. + /// + /// + /// Three states in one nullable string, and the empty one is not an oversight. Null used to mean "no + /// username", which the connect path refuses; it now means "whatever the group says", so the refusal + /// still has to be reachable or a host under a group could not opt out of the group's user. An empty + /// string is that opt-out: stored, resolved to nothing, and refused at connect with the same message it + /// always gave. + /// public string? Username { get; init; } /// Free-text notes. @@ -58,10 +85,17 @@ public sealed record HostSecret : IVaultSecret public HostOptions Options { get; init; } = HostOptions.Empty; /// - /// The vault SSH key to authenticate with, or null to use a password. + /// The vault SSH key to authenticate with, or null to take the group's binding. /// /// /// + /// Null used to mean "use a typed password" and now means "ask the group". Nothing already stored + /// changed meaning when it did: no group could lend a binding until this build, so every host in every + /// vault that named neither a key nor a credential resolved then, and resolves now, to a password typed + /// at connect time. What is new is that a host under a group which does lend one can no longer + /// say "not that, ask me" by naming nothing — which is what is for. + /// + /// /// An item id rather than the key itself, because the key is a vault item in its own right and a copy /// embedded here would be a second copy of a private key to keep in step — rotated in one place and /// stale in the other. The cost is that the reference can dangle: the key may be deleted on another @@ -83,14 +117,15 @@ public sealed record HostSecret : IVaultSecret public Guid? SshKeyId { get; init; } /// - /// The vault credential to authenticate with, or null to be asked for a password. + /// The vault credential to authenticate with, or null to take the group's binding. /// /// /// /// The password counterpart of , with the same reasoning about ids rather than - /// copies, the same dangling-reference handling, and the same refusal to fall back when the reference - /// cannot be resolved. One credential is very often the same account on twenty hosts, which is exactly - /// why it is referenced and not embedded — a copy per host is twenty places to rotate and one to forget. + /// copies, the same dangling-reference handling, the same meaning for null, and the same refusal to fall + /// back when the reference cannot be resolved. One credential is very often the same account on twenty + /// hosts, which is exactly why it is referenced and not embedded — a copy per host is twenty places to + /// rotate and one to forget. /// /// /// Mutually exclusive with . SSH itself would happily try a key and fall @@ -98,9 +133,61 @@ public sealed record HostSecret : IVaultSecret /// answer — and the interface, the connect path and the user would each be free to guess differently. /// One host, one method; enforces it. /// + /// + /// Enforcing it per record is necessary and not sufficient. A host naming a credential under a + /// group naming a key is two individually valid records that resolve to two bindings, so the exclusion + /// is applied again across the chain — host over group — where the chain is walked. Nothing that + /// resolves a host may return both. + /// /// public Guid? CredentialId { get; init; } + /// + /// Whether this host is pinned to a password typed at connect time, or null to leave it unstated. + /// + /// + /// + /// The fourth state, and the reason it needs a field of its own. A host can authenticate with a key, + /// with a stored credential, with a password typed each time, or with whatever its group lends it — and + /// two nullable ids can only express three of those. Naming neither id used to be the third; it is now + /// the fourth, so the third needs saying out loud. + /// + /// + /// Only means anything. Null is "not stated", which resolves through the + /// group chain and lands on a typed password if the chain lends nothing — so null and false would say + /// the same thing, and the codec writes null rather than false so that a host which never touched this + /// encodes exactly as it did before the field existed. A decoded is folded back + /// to null for the same reason: two spellings of one state is a difference the merge would report as a + /// change nobody made. + /// + /// + /// Refused beside a binding. A host that names a key and also says "ask me for a password" has + /// given two answers to one question, which is the same failure and + /// are kept apart to avoid. + /// + /// + public bool? AsksForPassword { get; init; } + + /// + /// The tags this host wears, as tag item ids. + /// + /// + /// + /// Ids rather than names, because a tag is a vault item in its own right — see + /// for why renaming one has to be a single write. The set lives here rather than on the tag, and rather + /// than in the HostTag join the contract reserves, because tagging two hosts must be two writes + /// to two items; the one thing the join would add on top of that is bought instead by merging this per + /// id. See . + /// + /// + /// The references may dangle, exactly as may. A tag deleted on another + /// machine leaves an id here that resolves to nothing, and the host simply stops drawing that chip — + /// handled where it is noticed rather than prevented here, because preventing it would mean one tag + /// delete rewriting every host that wore it. + /// + /// + public TagSet TagIds { get; init; } = TagSet.Empty; + /// /// The group this host is filed under, or null for none. /// @@ -168,12 +255,31 @@ public sealed record HostSecret : IVaultSecret return false; } + // Null is "take the group's port" and passes this comparison, which is the wanted behaviour and is + // silent about it: `is < 1 or > 65535` is false for a null int?. The message is interpolated only + // on the branch where the value exists, because a null renders as an empty string and would produce + // "not ." on screen. if (Port is < 1 or > 65535) { - reason = $"Port must be between 1 and 65535, not {Port}."; + reason = $"Port must be between 1 and 65535, not {Port.Value}."; return false; } + return ReferencesAreStorable(out reason); + } + + /// + /// Checks the five places a host points at something else, and the one answer it must not give twice. + /// + /// + /// Split out for length and they do belong together: every check here is about an id, and an id has + /// exactly two ways to be wrong. It can be , which is not "nothing" — nothing is + /// null — but a reference that can never resolve, so storing one produces a host that refuses to connect + /// with nothing on screen to say why. Or two ids can be present that answer the same question, which + /// leaves the interface, the connect path and the user each free to guess differently. + /// + private bool ReferencesAreStorable([NotNullWhen(false)] out string? reason) + { if (JumpHostIds.AsSpan().Contains(Guid.Empty)) { reason = "A jump chain cannot contain an empty host id."; @@ -182,8 +288,6 @@ public sealed record HostSecret : IVaultSecret if (SshKeyId == Guid.Empty) { - // An empty id is not "no key" — that is null. It is a reference that can never resolve, and - // storing one would produce a host that refuses to connect with no way to see why. reason = "An SSH key reference cannot be an empty id; use no key instead."; return false; } @@ -200,12 +304,24 @@ public sealed record HostSecret : IVaultSecret return false; } + if (AsksForPassword is true && (SshKeyId is not null || CredentialId is not null)) + { + reason = "A host asks for a password or binds a key or credential, not both."; + return false; + } + if (GroupId == Guid.Empty) { reason = "A group reference cannot be an empty id; use no group instead."; return false; } + if (TagIds.Contains(Guid.Empty)) + { + reason = "A tag reference cannot be an empty id."; + return false; + } + reason = null; return true; } diff --git a/src/DodoSSH.Client.Domain/HostSecretCodec.cs b/src/DodoSSH.Client.Domain/HostSecretCodec.cs index d39b730..a636b3e 100644 --- a/src/DodoSSH.Client.Domain/HostSecretCodec.cs +++ b/src/DodoSSH.Client.Domain/HostSecretCodec.cs @@ -65,8 +65,49 @@ public static class HostSecretCodec /// The version that introduced . public const int GroupIdSchemaVersion = 4; + /// + /// The version that introduced inheritance: a null and + /// . + /// + /// + /// + /// The one version where "read-only on an older client" understates the cost. Every field before + /// this one is additive: an older build decodes the host, shows it, and refuses to save it. A null port + /// is subtractive — the property is omitted, an older build's int Port reads 0, and + /// refuses the host outright. The item does not appear locked on + /// that machine; it does not appear at all. + /// + /// + /// Accepted rather than worked around, because the two workarounds are worse. Writing 22 into every host + /// makes inheritance a lie the moment a group's default changes, and it is what inheritance exists to + /// stop. Keeping a second non-inheriting port field beside this one would mean two ports per host and a + /// rule about which wins, on every screen and on the wire. What confines the cost is + /// : only a host that actually inherits its port is written here. + /// + /// + /// shares this version because it arrives in the same build and + /// answers the same question — it is what a host says instead of naming a binding, once naming nothing + /// has come to mean "ask the group". + /// + /// + public const int PortInheritSchemaVersion = 5; + + /// The version that introduced . + /// + /// One past inheritance rather than sharing with it, because the two are independent: a host can wear + /// tags without inheriting anything, and such a host stays decodable on a build that knows 5. That + /// distinction is unobservable today — both shipped together — and it is stated anyway, because the rule + /// this file follows is one version per field and the exception would have to be re-justified by whoever + /// adds the seventh. + /// + public const int TagIdsSchemaVersion = 6; + /// The highest schema version this build can write. - public const int CurrentSchemaVersion = GroupIdSchemaVersion; + /// + /// Names the highest constant above, which assumes when it takes a + /// maximum. A new field added below this line has to be named here too. + /// + public const int CurrentSchemaVersion = TagIdsSchemaVersion; /// Serialises a host to the bytes that get sealed. /// The host is not valid for storage. @@ -99,6 +140,15 @@ public static class HostSecretCodec SshKeyId = host.SshKeyId, CredentialId = host.CredentialId, GroupId = host.GroupId, + + // Null rather than false, so a host that never said anything about this encodes exactly as it + // did before the field existed. See HostSecret.AsksForPassword. + AsksForPassword = host.AsksForPassword is true ? true : null, + + // Null rather than an empty array, for the same reason and with a wider blast radius: an empty + // [] here would land in every host in every vault and make the first sync after the upgrade + // read as though every one of them had changed. + TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds], }; return JsonSerializer.SerializeToUtf8Bytes( @@ -156,6 +206,19 @@ public static class HostSecretCodec version = Math.Max(version, GroupIdSchemaVersion); } + // Both halves of inheritance, and this is the branch that loses an item rather than locking one if + // it is forgotten — see PortInheritSchemaVersion. A host stamped at 4 with its port omitted is a + // host an older client deletes from its own view. + if (host.Port is null || host.AsksForPassword is true) + { + version = Math.Max(version, PortInheritSchemaVersion); + } + + if (host.TagIds.Count > 0) + { + version = Math.Max(version, TagIdsSchemaVersion); + } + return version; } @@ -226,6 +289,12 @@ public static class HostSecretCodec SshKeyId = parsed.SshKeyId, CredentialId = parsed.CredentialId, GroupId = parsed.GroupId, + + // False folds back to null: they say the same thing, and letting both onto the record would + // give the merge two spellings of one state to report as a change nobody made. + AsksForPassword = parsed.AsksForPassword is true ? true : null, + + TagIds = TagSet.Create(parsed.TagIds ?? []), }; if (!candidate.TryValidate(out _)) @@ -255,7 +324,13 @@ internal sealed class HostPayloadDocument public string? Hostname { get; set; } - public int Port { get; set; } + /// + /// Nullable, which is what removes the key from the JSON for a host that inherits its port. It is also + /// the one property here whose absence an older build cannot survive: it deserialises as + /// 0 there, which refuses. See + /// . + /// + public int? Port { get; set; } public string? Username { get; set; } @@ -284,6 +359,16 @@ internal sealed class HostPayloadDocument /// public Guid? GroupId { get; set; } + + /// + public bool? AsksForPassword { get; set; } + + /// + /// Last, and it must stay last for the reason gives. Null when the host wears no + /// tags, never [] — an empty array would be a new key in the JSON of every host in every vault, + /// which the sync engine would read as every host having changed. + /// + public Guid[]? TagIds { get; set; } } [JsonSourceGenerationOptions( diff --git a/src/DodoSSH.Client.Domain/HostSecretMerge.cs b/src/DodoSSH.Client.Domain/HostSecretMerge.cs index 48f6780..871d402 100644 --- a/src/DodoSSH.Client.Domain/HostSecretMerge.cs +++ b/src/DodoSSH.Client.Domain/HostSecretMerge.cs @@ -84,7 +84,7 @@ public static class HostSecretMerge local.Port, remote.Port, conflicts, - static port => port.ToString(CultureInfo.InvariantCulture)), + static port => port?.ToString(CultureInfo.InvariantCulture) ?? "the group's port"), Username = Text( nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts), Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts), @@ -96,6 +96,7 @@ public static class HostSecretMerge conflicts, FormatChain), Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts), + TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts), RelayEnabled = Field( nameof(HostSecret.RelayEnabled), ancestor.RelayEnabled, @@ -155,6 +156,14 @@ public static class HostSecretMerge remote.GroupId, conflicts, static id => id?.ToString() ?? "ungrouped"), + + AsksForPassword = Field( + nameof(HostSecret.AsksForPassword), + ancestor.AsksForPassword, + local.AsksForPassword, + remote.AsksForPassword, + conflicts, + static asked => asked is true ? "a typed password" : "the group's binding"), }; private static string Text( @@ -231,6 +240,56 @@ public static class HostSecretMerge merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value))); } + /// + /// Merges the tag set per tag, so two people each adding a different one both keep theirs. + /// + /// + /// + /// The reason exposes a map at all. A whole-value merge here would take one side's + /// set entire and drop the other's — so a colleague tagging a host "pci" while you tagged it "eu-west" + /// would silently lose one of the two, which is the single most visible difference between a field-level + /// merge and last-writer-wins. + /// + /// + /// No conflict is reachable, and the loop below is kept anyway. The value in the map is the key, + /// so a tag can only be present or absent — and running that through + /// leaves nothing to disagree about. Where both sides hold the tag or + /// neither does they agree; where exactly one side moved, the other still matches the ancestor and the + /// move is taken. The "both sides moved differently" branch needs one key to hold two values, which this + /// map cannot express. A set is the one collection shape that merges without ever asking the user. + /// + /// + /// The loop stays because that proof depends on keying by the value, which + /// is one edit away from ceasing to be true — and the failure it would cause is a discarded tag that + /// nothing records. Unreachable code that costs a foreach over an empty list is a cheaper way to + /// hold that invariant than a comment alone. + /// + /// + private static TagSet MergeTags( + TagSet ancestor, + TagSet local, + TagSet remote, + List conflicts) + { + var merge = ThreeWayMerge.Map( + ancestor.ToIdMap(), + local.ToIdMap(), + remote.ToIdMap(), + EqualityComparer.Default); + + foreach (var conflict in merge.Conflicts) + { + conflicts.Add(new HostFieldConflict( + $"{nameof(HostSecret.TagIds)}[{conflict.Key}]", + conflict.DiscardedSide, + conflict.Kept == Guid.Empty ? "not tagged" : "tagged", + conflict.DiscardedWasRemoval ? "not tagged" : "tagged", + conflict.DiscardedWasRemoval)); + } + + return TagSet.Create(merge.Merged.Keys); + } + private static string FormatChain(JumpChain chain) => chain.Count == 0 ? "(none)" : string.Join(" → ", chain); } diff --git a/src/DodoSSH.Client.Domain/TagSecret.cs b/src/DodoSSH.Client.Domain/TagSecret.cs new file mode 100644 index 0000000..8a0561d --- /dev/null +++ b/src/DodoSSH.Client.Domain/TagSecret.cs @@ -0,0 +1,61 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DodoSSH.Client.Domain; + +/// +/// A label that can be put on many hosts, decrypted. +/// +/// +/// +/// One field, and the one field is the whole argument for the type existing. A tag is only worth having +/// because the same tag goes on twenty machines and filters them back out later, so it needs an identity of +/// its own: renaming "staging" to "stage" is then one write to one item, rather than twenty host payloads +/// that have to be found, decrypted, edited and pushed — nineteen of which can fail halfway and leave a +/// vault holding two spellings of one tag. +/// +/// +/// Membership is a set on the host, not a member list here and not the +/// HostTag join the contract reserves. The pointer-on-the-host argument that decided +/// applies unchanged — tagging two hosts is two writes to two items — and +/// the extra thing a join would have bought, two machines tagging the same host without one losing, +/// is already bought by , which resolves a keyed collection key by key and so +/// gives set semantics with removals. SyncEntityType.HostTag therefore stays reserved and unused. +/// +/// +/// No colour, no description, no ordering. Each was considered and each would be a field two clients +/// can disagree about in exchange for nothing the filter needs. If one arrives later it arrives behind a +/// schema version, which is what the envelope in is for. +/// +/// +public sealed record TagSecret : IVaultSecret +{ + /// What the tag is called. The only name it has anywhere. + public required string Label { get; init; } + + /// Whether this is storable, and why not if it is not. + /// + /// + /// A blank name is refused rather than defaulted, for a sharper reason than a group's: a tag is drawn as + /// a chip beside a host and selected from a list of chips, so a nameless one is an empty chip that + /// filters a set nobody can name. + /// + /// + /// A duplicate name is not refused here, and cannot be — this record is one tag and knows nothing + /// about the others. Two tags called "staging" are two tags, which is a mess the editor should warn about + /// and the vault must still be able to hold: two people creating the same tag offline is exactly how it + /// happens, and refusing to store the second one would mean dropping the tags a colleague had already + /// put on their hosts. + /// + /// + public bool TryValidate([NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(Label)) + { + reason = "A tag needs a name."; + return false; + } + + reason = null; + return true; + } +} diff --git a/src/DodoSSH.Client.Domain/TagSecretCodec.cs b/src/DodoSSH.Client.Domain/TagSecretCodec.cs new file mode 100644 index 0000000..364658a --- /dev/null +++ b/src/DodoSSH.Client.Domain/TagSecretCodec.cs @@ -0,0 +1,119 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DodoSSH.Client.Domain; + +/// A decoded tag payload, together with the schema version it was written at. +/// The tag. +/// The version the writing client used. +public sealed record TagSecretDocument(TagSecret Tag, int SchemaVersion) +{ + /// + public bool IsReadOnly => SchemaVersion > TagSecretCodec.CurrentSchemaVersion; +} + +/// +/// Encodes and decodes the plaintext inside a tag item's encrypted payload. +/// +/// +/// Mirrors , for the same reasons and with the same guarantees. One field +/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema +/// version, which is what lets a later build add a field without every older client silently dropping it on +/// the next edit. is the type that had to spend that budget, and the shape +/// here is the shape it had beforehand. See . +/// +public static class TagSecretCodec +{ + /// The first version, and the one a tag with no newer field is still written at. + public const int BaseSchemaVersion = 1; + + /// The highest schema version this build can write. + public const int CurrentSchemaVersion = BaseSchemaVersion; + + /// Serialises a tag to the bytes that get sealed. + /// The tag is not valid for storage. + public static byte[] Encode(TagSecret tag) + { + ArgumentNullException.ThrowIfNull(tag); + + if (!tag.TryValidate(out var reason)) + { + throw new ArgumentException(reason, nameof(tag)); + } + + var document = new TagPayloadDocument + { + SchemaVersion = SchemaVersionFor(tag), + Label = tag.Label, + }; + + return JsonSerializer.SerializeToUtf8Bytes( + document, TagPayloadJsonContext.Default.TagPayloadDocument); + } + + /// + /// The lowest schema version that can represent this tag without losing anything. + /// + /// + /// With one field there is nothing to take a maximum over, and this returns a constant. It is a method + /// anyway, because the alternative — stamping at the call site — is + /// indistinguishable from this today and becomes wrong the moment a second field exists: the version is + /// what makes an older client treat an item as read-only, so a build that stamps the newest one + /// unconditionally makes every tag in the vault uneditable everywhere as soon as one machine upgrades + /// and renames one. shipped without this and had to grow it; the + /// method is here so the second field has an obvious place to go rather than a call site to notice. + /// + private static int SchemaVersionFor(TagSecret tag) => BaseSchemaVersion; + + /// Parses a decrypted payload. + /// + public static bool TryDecode( + ReadOnlySpan payload, + [NotNullWhen(true)] out TagSecretDocument? document) + { + document = null; + + TagPayloadDocument? parsed; + try + { + parsed = JsonSerializer.Deserialize( + payload, TagPayloadJsonContext.Default.TagPayloadDocument); + } + catch (JsonException) + { + return false; + } + + if (parsed is null || parsed.SchemaVersion < 1) + { + return false; + } + + var candidate = new TagSecret { Label = parsed.Label ?? string.Empty }; + + if (!candidate.TryValidate(out _)) + { + return false; + } + + document = new TagSecretDocument(candidate, parsed.SchemaVersion); + return true; + } +} + +/// The serialised shape. Mutable and nullable because it models untrusted input. +/// +internal sealed class TagPayloadDocument +{ + public int SchemaVersion { get; set; } + + public string? Label { get; set; } +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] +[JsonSerializable(typeof(TagPayloadDocument))] +internal sealed partial class TagPayloadJsonContext : JsonSerializerContext; diff --git a/src/DodoSSH.Client.Domain/TagSecretMerge.cs b/src/DodoSSH.Client.Domain/TagSecretMerge.cs new file mode 100644 index 0000000..8b6ac5e --- /dev/null +++ b/src/DodoSSH.Client.Domain/TagSecretMerge.cs @@ -0,0 +1,68 @@ +namespace DodoSSH.Client.Domain; + +/// The merged tag, and everything that had to be overridden to produce it. +/// The tag to store and push. +/// Empty when the two sides were reconcilable field by field. +public sealed record TagMergeResult( + TagSecret Merged, + IReadOnlyList Conflicts) +{ + /// Whether anything had to be overridden. + public bool HasConflicts => Conflicts.Count > 0; +} + +/// +/// Merges two divergent versions of a tag against the version they both started from. +/// +/// +/// +/// One scalar, so this is now the simplest merge in the client, and the interesting thing about it is what +/// it never has to consider. Putting a tag on a host does not write to the tag — membership is a set on the +/// host — so two people tagging two different machines at the same time cannot reach this code. The only way +/// to get here is for two people to rename the same tag differently, which is a real disagreement and gets a +/// conflict notice. +/// +/// +/// Two tags that end up with the same name are not merged into one, here or anywhere. They are two +/// items with two ids and two sets of hosts, and collapsing them would mean rewriting every host that named +/// the loser — the exact N-payload write that keeping membership on the host exists to avoid. A duplicate is +/// something the editor should warn about before it is created, not something the merge silently repairs +/// afterwards. +/// +/// +/// Nothing is redacted. A tag name is the one thing a tag has, and a notice saying only that "the name +/// differed" would leave the user unable to tell which of their two names survived. +/// +/// +public static class TagSecretMerge +{ + /// Produces the merged tag. + /// The version both sides branched from. + /// The pending local version. + /// The server's current version. + public static TagMergeResult Merge(TagSecret ancestor, TagSecret local, TagSecret remote) + { + ArgumentNullException.ThrowIfNull(ancestor); + ArgumentNullException.ThrowIfNull(local); + ArgumentNullException.ThrowIfNull(remote); + + var conflicts = new List(); + + var merge = ThreeWayMerge.Scalar( + ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal); + + if (merge.IsConflicted) + { + // The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is + // fixed here rather than derived from the outcome. + conflicts.Add(new HostFieldConflict( + nameof(TagSecret.Label), + MergeSide.Local, + merge.Value, + merge.Discarded, + DiscardedWasRemoval: false)); + } + + return new TagMergeResult(new TagSecret { Label = merge.Value }, conflicts); + } +} diff --git a/src/DodoSSH.Client.Domain/TagSet.cs b/src/DodoSSH.Client.Domain/TagSet.cs new file mode 100644 index 0000000..8b26f0c --- /dev/null +++ b/src/DodoSSH.Client.Domain/TagSet.cs @@ -0,0 +1,164 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace DodoSSH.Client.Domain; + +/// +/// The tags a host wears, as item ids, in no meaningful order. +/// +/// +/// +/// A dedicated type rather than a list of ids, for the reason gives: a plain +/// on a record gets reference equality from the compiler-generated +/// Equals, so every host would read as changed on every sync pass and two identical edits would +/// register as a conflict. +/// +/// +/// Unlike a jump chain, this is a set, and the difference is the whole point. A route's order is its +/// meaning; a tag list's order is an artefact of which chip the user tapped first. So this sorts and +/// deduplicates at construction, which makes two users who added the same two tags in opposite orders +/// produce the same value — and therefore no change to push and nothing to merge. It also means the merge +/// may treat it as a set: see . +/// +/// +/// Empty ids are not rejected at construction. They arrive from a decrypted payload written by another +/// client, and a constructor that threw would turn one bad item into a failed sync pass for every other item +/// behind it. is where that is caught. +/// +/// +public sealed class TagSet : IReadOnlyList, IEquatable +{ + private readonly Guid[] ids; + private readonly int hash; + + private TagSet(Guid[] ids) + { + this.ids = ids; + hash = ComputeHash(ids); + } + + /// No tags. + public static TagSet Empty { get; } = new([]); + + /// + public int Count => ids.Length; + + /// + public Guid this[int index] => ids[index]; + + /// Copies a sequence of tag ids, sorting and removing repeats. + public static TagSet Create(IEnumerable ids) + { + ArgumentNullException.ThrowIfNull(ids); + + return Canonicalise([.. ids]); + } + + /// Copies a span of tag ids, sorting and removing repeats. + public static TagSet Create(ReadOnlySpan ids) => Canonicalise(ids.ToArray()); + + /// Whether this host wears the given tag. + public bool Contains(Guid id) => Array.BinarySearch(ids, id) >= 0; + + /// + /// The set as a map from tag id to tag id, which is the shape takes. + /// + /// + /// + /// The value repeats the key deliberately. A per-key merge resolves presence and absence independently, + /// which is set semantics with removals — so two people each adding a different tag to one host both + /// keep theirs, and a removal on one side is reported rather than silently undone. A whole-value merge + /// would drop one side outright. + /// + /// + /// This is the reason there is no HostTag join item. The one thing a join buys over a set + /// on the host — two machines tagging the same host without either losing — is exactly what a keyed + /// merge already gives. See . + /// + /// + /// Because the value is the key, no key can ever have two different values, so a conflict here can only + /// ever be one side adding what the other removed. That is the only disagreement a set can have. + /// + /// + public IReadOnlyDictionary ToIdMap() => ids.ToDictionary(id => id); + + /// + public bool Equals(TagSet? other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + return other is not null + && other.hash == hash + && ids.AsSpan().SequenceEqual(other.ids); + } + + /// + public override bool Equals(object? obj) => Equals(obj as TagSet); + + /// + public override int GetHashCode() => hash; + + /// + public IEnumerator GetEnumerator() => ((IEnumerable)ids).GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => ids.GetEnumerator(); + + /// The ids, without copying, in sorted order. + public ReadOnlySpan AsSpan() => ids; + + /// Contents equality, tolerating nulls on either side. + [SuppressMessage( + "Usage", + "CA2225:Operator overloads have named alternates", + Justification = "Equals(TagSet) is the named alternate.")] + public static bool operator ==(TagSet? left, TagSet? right) => + left is null ? right is null : left.Equals(right); + + /// Contents inequality. + public static bool operator !=(TagSet? left, TagSet? right) => !(left == right); + + /// + /// Sorting is what makes the sequence comparison below a set comparison, and deduplicating is what stops + /// a payload that repeats an id from comparing unequal to the same set written once. Both happen here, + /// once, rather than at every comparison. + /// + private static TagSet Canonicalise(Guid[] ids) + { + if (ids.Length == 0) + { + return Empty; + } + + Array.Sort(ids); + + var written = 1; + for (var read = 1; read < ids.Length; read++) + { + if (ids[read] != ids[written - 1]) + { + ids[written++] = ids[read]; + } + } + + return new TagSet(written == ids.Length ? ids : ids[..written]); + } + + private static int ComputeHash(Guid[] ids) + { + // Order-sensitive over an already-sorted array, which is order-insensitive over the set. Written + // this way rather than by summing or xoring hashes because those collide on swapped pairs. + var accumulator = new HashCode(); + accumulator.Add(ids.Length); + + foreach (var id in ids) + { + accumulator.Add(id); + } + + return accumulator.ToHashCode(); + } +} diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs index a36a43e..c9f8f31 100644 --- a/src/DodoSSH.Client.Session/VaultSession.cs +++ b/src/DodoSSH.Client.Session/VaultSession.cs @@ -122,6 +122,7 @@ public sealed partial class VaultSession : IAsyncDisposable Credentials = new CredentialRepository(Items, Outbox, keyring, activity); KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity); HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity); + Tags = new TagRepository(Items, Outbox, keyring, activity); Snippets = new SnippetRepository(Items, Outbox, keyring, activity); ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity); } @@ -175,12 +176,20 @@ public sealed partial class VaultSession : IAsyncDisposable /// The groups hosts are filed under, decrypted, with unpushed local changes laid over them. /// - /// Membership is not in here. Each host carries its own GroupId, so a group is only ever a name — - /// which is what makes filing two hosts at once on two machines two independent writes rather than one - /// contested one. + /// Membership is not in here. Each host carries its own GroupId, so a group is only ever a name, + /// a parent and the defaults hosts under it fall back to — which is what makes filing two hosts at once + /// on two machines two independent writes rather than one contested one. /// public HostGroupRepository HostGroups { get; } + /// The tags hosts wear, decrypted, with unpushed local changes laid over them. + /// + /// Membership is not in here either, and for the same reason once removed: each host carries its own set + /// of tag ids. Read beside whenever a host list is drawn, because a tag id on a + /// host resolves to a name only through this. + /// + public TagRepository Tags { get; } + /// Saved commands, decrypted, with unpushed local changes laid over them. public SnippetRepository Snippets { get; } diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index 5ac98eb..ae65195 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -804,6 +804,17 @@ internal sealed partial class VaultViewModel( /// private IReadOnlyList> groupItems = []; + /// + /// The same groups by id, which is the shape the inheritance walk takes. + /// + /// + /// Cached beside rather than built per call, because resolving is on the path + /// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host + /// would be one allocation per row per redraw. + /// + private IReadOnlyDictionary groupsById = + new Dictionary(); + /// The groups whose hosts are folded away, by id, with for ungrouped. private readonly HashSet collapsedGroups = []; @@ -1813,10 +1824,30 @@ internal sealed partial class VaultViewModel( .ConfigureAwait(true); groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)]; + groupsById = groupItems.ToDictionary(group => group.EntityId, group => group.Secret); return listing.Unreadable; } + /// + /// A host with its group chain applied: the port to dial, the user to log in as, and how to + /// authenticate. + /// + /// + /// + /// The one place this view model turns a stored host into a dialled one. A host may leave any of the + /// three unset and take its group's, so reading host.Port or host.SshKeyId directly + /// answers "what did the user type into this host" and not "what happens when this is connected" — and + /// almost everything on screen wants the second question. See . + /// + /// + /// Reads , which is refilled by before the hosts + /// are read. That ordering is not incidental: a host resolved against a stale group list would show one + /// port and dial another. + /// + /// + internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById); + /// Refills , counting the hosts filed under each. private void RebuildGroups() { @@ -2177,7 +2208,7 @@ internal sealed partial class VaultViewModel( // Built once rather than searched per pin. A vault with a hundred of each would otherwise be a // hundred scans of the host list on every background sync. var dialled = Hosts - .Select(host => Endpoint(host.Host.Hostname, host.Host.Port)) + .Select(host => Endpoint(host.Host.Hostname, Resolve(host.Host).Port.Value)) .ToHashSet(StringComparer.OrdinalIgnoreCase); // Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in @@ -2521,7 +2552,10 @@ internal sealed partial class VaultViewModel( editingHostVaultId = row.VaultId; EditorLabel = row.Host.Label; EditorHostname = row.Host.Hostname; - EditorPort = row.Host.Port; + // The resolved port rather than the stored one, so a host that inherits opens showing what it + // actually dials rather than an empty box. The editor cannot yet express "leave this to the group", + // so saving pins whatever is shown — which is what it did before any of this existed. + EditorPort = Resolve(row.Host).Port.Value; EditorUsername = row.Host.Username ?? string.Empty; EditorNotes = row.Host.Notes ?? string.Empty; EditorRelayEnabled = row.Host.RelayEnabled; @@ -3807,7 +3841,11 @@ internal sealed partial class VaultViewModel( } var address = row.Host.Hostname; - var port = row.Host.Port; + + // The dialled port, because that is what the pin is filed under. A host inheriting 2222 from its + // group was pinned at 2222, and forgetting under 22 would leave the pin that caused the mismatch + // exactly where it was. + var port = Resolve(row.Host).Port.Value; await RunAsync( $"Forgetting the pinned host key for {address}…", @@ -4004,7 +4042,7 @@ internal sealed partial class VaultViewModel( var request = new SshConnectionRequest( row.Host.Hostname, - row.Host.Port, + Resolve(row.Host).Port.Value, authentication.Username, authentication.Credential); @@ -4170,7 +4208,10 @@ internal sealed partial class VaultViewModel( } request = new SshConnectionRequest( - host.Hostname, host.Port, authentication.Username, authentication.Credential); + host.Hostname, + Resolve(host).Port.Value, + authentication.Username, + authentication.Credential); return true; } diff --git a/src/DodoSSH.Client.Sync/ItemKinds.cs b/src/DodoSSH.Client.Sync/ItemKinds.cs index 797b240..32cc28f 100644 --- a/src/DodoSSH.Client.Sync/ItemKinds.cs +++ b/src/DodoSSH.Client.Sync/ItemKinds.cs @@ -154,6 +154,9 @@ internal static class ItemKinds (SyncEntityType.HostGroup, static (outbox, conflicts, keyring) => new ItemReconciler(HostGroupKind.Instance, outbox, conflicts, keyring)), + (SyncEntityType.Tag, static (outbox, conflicts, keyring) => + new ItemReconciler(TagKind.Instance, outbox, conflicts, keyring)), + (SyncEntityType.Snippet, static (outbox, conflicts, keyring) => new ItemReconciler(SnippetKind.Instance, outbox, conflicts, keyring)), @@ -267,7 +270,9 @@ internal sealed class HostKind : IItemKind Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled); Note(changed, "SSH key", before.SshKeyId, after.SshKeyId); Note(changed, "Credential", before.CredentialId, after.CredentialId); + Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword); Note(changed, "Group", before.GroupId, after.GroupId); + Note(changed, "Tags", before.TagIds, after.TagIds); return changed; } @@ -588,13 +593,14 @@ internal sealed class HostGroupKind : IItemKind HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// - /// Nothing, and the field it declines to send is the one named after this type. + /// Nothing, and the two fields it declines to send are the two this type could have filled. /// /// - /// SyncPlaintextFields.GroupId exists, the server had a column for it, and no client ever wrote - /// one. What it would have handed over is a clustering of the estate — which machines this user files - /// together — for a column nothing in the product reads. The server now refuses the field outright, on - /// hosts as well as here. See ADR 0004. + /// SyncPlaintextFields has a GroupId and a ParentId, the server had a column for the + /// first, and no client ever wrote either. What they would hand over is the shape of the estate — which + /// machines this user files together, and which of those groupings sit under which — for columns nothing + /// in the product reads. Groups nest now and the parent is still not sent: it lives in the payload, and + /// the server refuses the plaintext field outright, on hosts as well as here. See ADR 0004. /// /// public SyncPlaintextFields? Fields(HostGroupSecret secret) => null; @@ -619,6 +625,11 @@ internal sealed class HostGroupKind : IItemKind var changed = new List(); Note(changed, "Name", before.Label, after.Label); + Note(changed, "Parent", before.ParentId, after.ParentId); + Note(changed, "Default port", before.DefaultPort, after.DefaultPort); + Note(changed, "Default username", before.DefaultUsername, after.DefaultUsername); + Note(changed, "Default SSH key", before.DefaultSshKeyId, after.DefaultSshKeyId); + Note(changed, "Default credential", before.DefaultCredentialId, after.DefaultCredentialId); return changed; } @@ -632,6 +643,88 @@ internal sealed class HostGroupKind : IItemKind } } +/// Tags. +internal sealed class TagKind : IItemKind +{ + internal static TagKind Instance { get; } = new(); + + /// + public SyncEntityType EntityType => SyncEntityType.Tag; + + /// + public string Noun => "tag"; + + /// + public OpenedItem? TryOpen( + EncryptedPayload payload, + ReadOnlySpan vaultKey, + Guid entityId, + int itemVersion) + { + var document = TagCipher.TryOpen(payload, vaultKey, entityId, itemVersion); + + return document is null ? null : new OpenedItem(document.Tag, document.IsReadOnly); + } + + /// + public EncryptedPayload Seal( + TagSecret secret, + ReadOnlySpan vaultKey, + Guid entityId, + uint keyGeneration, + int itemVersion) => + TagCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); + + /// + /// Nothing, and null rather than an empty . + /// + /// + /// + /// The distinction is not pedantry. An empty record still serialises relayEnabled: false, which + /// invites every reader of the wire — including a future implementation of this client — to conclude + /// that a tag has a relay setting and that it is switched off. Null says the type has no plaintext at + /// all, which is the true statement. + /// + /// + /// A tag name in the clear would be the most aggregable column in the schema. Groups file machines; + /// tags describe them — "pci", "customer-a", "eu-west" — and one tag spans twenty hosts by design, so a + /// column here would hand the operator a labelled map of every user's estate for a sort no server in + /// this product performs. See ADR 0004. + /// + /// + /// + public SyncPlaintextFields? Fields(TagSecret secret) => null; + + /// + public MergedItem Merge(TagSecret ancestor, TagSecret local, TagSecret remote) + { + var merged = TagSecretMerge.Merge(ancestor, local, remote); + + return new MergedItem(merged.Merged, merged.Conflicts); + } + + /// + public IReadOnlyList Changes(TagSecret before, TagSecret after) + { + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(after); + + var changed = new List(); + + Note(changed, "Name", before.Label, after.Label); + + return changed; + } + + /// + public TagSecret Relabel(TagSecret secret, string label) + { + ArgumentNullException.ThrowIfNull(secret); + + return secret with { Label = label }; + } +} + /// Snippets. internal sealed class SnippetKind : IItemKind { diff --git a/src/DodoSSH.Client.Sync/TagCipher.cs b/src/DodoSSH.Client.Sync/TagCipher.cs new file mode 100644 index 0000000..301482f --- /dev/null +++ b/src/DodoSSH.Client.Sync/TagCipher.cs @@ -0,0 +1,132 @@ +using System.Security.Cryptography; +using DodoSSH.Client.Domain; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Sync; + +/// +/// Turns a tag into an item payload and back. +/// +/// +/// +/// Mirrors exactly, including the rule that a payload is sealed at the version +/// the server will assign rather than the one it replaces — see . +/// +/// +/// The resource type is the one thing not to copy, and this type is where copying it is most tempting. +/// SyncEntityType.Tag is 5 and CryptoSpec.AadResourceType.Tag is 8, because the crypto enum +/// carries None, User, Device and Vault ahead of the item types. A cast from one to the other compiles, and +/// 5 in the crypto enum is Credential — so every tag in the vault would be sealed under the resource +/// type for a password. It would encrypt perfectly and decrypt perfectly on the machine that wrote it, and +/// the first thing to notice would be another implementation refusing the item. The AAD is frozen into +/// stored ciphertext, so by then it is not a bug that can be fixed by a release. +/// +/// +public static class TagCipher +{ + private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Tag; + + /// Encrypts a tag. + /// The tag. Must be valid for storage. + /// The vault key, which the data key is wrapped under. + /// The item id, which the AAD binds. + /// The vault's current key generation. + /// The version this payload will hold once the server accepts it. + public static EncryptedPayload Seal( + TagSecret tag, + ReadOnlySpan vaultKey, + Guid entityId, + uint keyGeneration, + int itemVersion) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1); + + var plaintext = TagSecretCodec.Encode(tag); + var dataKey = ItemKeys.CreateDataKey(); + + try + { + var dataKeyId = Guid.CreateVersion7(); + + var wrappedDataKey = ItemKeys.WrapDataKey( + dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion); + + var envelope = ItemKeys.SealPayload( + dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion); + + return new EncryptedPayload( + envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion); + } + finally + { + CryptographicOperations.ZeroMemory(dataKey); + + // Wiped like every other payload in this folder, and here for the smallest reason of all: the + // buffer holds one word somebody chose for a filter. It is wiped anyway, because the rule this + // folder follows is that plaintext does not outlive the call that made it, and an exception for + // the case that seems harmless is how the rule stops being one. + CryptographicOperations.ZeroMemory(plaintext); + } + } + + /// Decrypts a tag. + /// + public static TagSecretDocument? TryOpen( + EncryptedPayload payload, + ReadOnlySpan vaultKey, + Guid entityId, + int itemVersion) + { + ArgumentNullException.ThrowIfNull(payload); + + if (itemVersion < 1 || payload.WrappedDataKey.Length == 0) + { + return null; + } + + var dataKey = ItemKeys.TryUnwrapDataKey( + vaultKey, + payload.WrappedDataKey, + Resource, + entityId, + payload.KeyGeneration, + (uint)itemVersion); + + if (dataKey is null) + { + return null; + } + + try + { + var plaintext = ItemKeys.TryOpenPayload( + dataKey, + payload.Envelope, + Resource, + entityId, + payload.DataKeyId, + payload.KeyGeneration, + (uint)itemVersion); + + if (plaintext is null) + { + return null; + } + + try + { + return TagSecretCodec.TryDecode(plaintext, out var document) ? document : null; + } + finally + { + CryptographicOperations.ZeroMemory(plaintext); + } + } + finally + { + CryptographicOperations.ZeroMemory(dataKey); + } + } +} diff --git a/src/DodoSSH.Client.Sync/TagRepository.cs b/src/DodoSSH.Client.Sync/TagRepository.cs new file mode 100644 index 0000000..342b213 --- /dev/null +++ b/src/DodoSSH.Client.Sync/TagRepository.cs @@ -0,0 +1,58 @@ +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; + +namespace DodoSSH.Client.Sync; + +/// +/// The tags in this vault, decrypted, with unpushed local changes laid over them. +/// +/// +/// +/// Another facade over the same generic repository, and like the ones before it, it needed no new sync logic +/// at all. +/// +/// +/// Deleting a tag does not touch the hosts wearing it, for the reason +/// gives about groups and one more besides. The shared reason: one user +/// action would become N host writes, N outbox rows and N chances to merge against an edit nobody made, and +/// the tag's own tombstone can still lose a merge — by which time the membership it was clearing is gone. +/// The extra one: a host can wear several tags, so unpicking one from N hosts is N read-modify-writes of a +/// set rather than N clears of a pointer, and any of them that loses a merge leaves the vault holding a tag +/// that was deleted and is still worn. Hosts left holding a dangling id simply do not show that chip. See +/// . +/// +/// +public sealed class TagRepository( + ItemStore items, + OutboxStore outbox, + VaultKeyring keyring, + IActivityLogSink? activity = null) +{ + private readonly VaultItemRepository tags = + new(TagKind.Instance, items, outbox, keyring, activity); + + /// + public Task> ListAsync( + Guid vaultId, + CancellationToken cancellationToken) => + tags.ListAsync(vaultId, cancellationToken); + + /// + public Task CreateAsync( + Guid vaultId, + TagSecret tag, + CancellationToken cancellationToken) => + tags.CreateAsync(vaultId, tag, cancellationToken); + + /// + public Task UpdateAsync( + Guid vaultId, + Guid entityId, + TagSecret tag, + CancellationToken cancellationToken) => + tags.UpdateAsync(vaultId, entityId, tag, cancellationToken); + + /// + public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => + tags.DeleteAsync(vaultId, entityId, cancellationToken); +} diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs index 6760e15..777bc88 100644 --- a/src/DodoSSH.Domain/Hosts.cs +++ b/src/DodoSSH.Domain/Hosts.cs @@ -327,15 +327,25 @@ public sealed class VaultKnownHostKey : IVaultItem /// /// /// -/// A group is a name and nothing else, so this row is the narrowest one in the schema: an envelope and its -/// bookkeeping. There is no parent_id and no name column, and both absences are deliberate. +/// Everything a group is — its name, the group it sits under, and the port, username and binding hosts +/// inside it fall back to — is inside the envelope, so this row is one of the narrowest in the schema: an +/// envelope and its bookkeeping. There is no parent_id and no name column, and both absences +/// are deliberate. /// /// -/// No parent, because groups are flat. A nesting pointer merged by a scalar three-way merge lets two -/// offline clients each re-parent A under B and B under A, and the result is a cycle the server cannot see — -/// the pointer would be inside the payload, which the server cannot read — and which every client would then -/// have to detect on every read, forever. Flat costs one level of organisation and removes a whole class of -/// unrepairable state. +/// No parent column, although groups do nest. The pointer exists; it lives in the payload, and the +/// push path refuses a plaintext ParentId outright. What a column here would hand the operator is the +/// shape of every user's estate — how many groupings, how deep, which under which — which is the same +/// disclosure a plaintext group_id on a host would have been, and ADR 0004 spends the one plaintext +/// concession this design allows on the relay address instead. +/// +/// +/// The consequence is that the server cannot see a cycle, and it was once the argument for having no parent +/// at all: two offline clients can each re-parent A under B and B under A, a scalar merge accepts both, and +/// nothing server-side can refuse the pair. That is still true and is now handled rather than avoided — +/// every walk of the chain carries a visited set and stops at a repeat, so a cycle degrades to a group that +/// reads as a root. See HostGroupSecret on the client, which is the only thing that can read any of +/// this. /// /// /// No name, for the reason has no host column. A group name is not @@ -407,6 +417,81 @@ public sealed class VaultHostGroup : IVaultItem public Guid UpdatedByUserId { get; set; } } +/// +/// A label that can be put on many hosts, as ciphertext. +/// +/// +/// +/// 's shape exactly — an envelope and its bookkeeping — and the reasoning for the +/// missing name column is the same argument at higher stakes. A group name describes how one user +/// files their machines; a tag name describes what the machines are, and one tag spans twenty hosts +/// by design. A column here would give the operator a labelled map of every estate on the server, to sort a +/// list nothing server-side draws. +/// +/// +/// No membership, and no join table either. Which hosts wear this tag is a set of ids inside each +/// host's payload. SyncEntityType.HostTag reserves a slot for the join and it stays reserved: the one +/// thing a join buys over a set on the host — two people tagging the same host without one of them losing — +/// is already bought by the client's per-key three-way merge, and a second item type is a second table, a +/// second migration and a second pass through every rule in this file for nothing. +/// +/// +public sealed class VaultTag : IVaultItem +{ + /// Primary key. UUIDv7, generated by the client so a tag 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 tag: 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; } + + /// 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 tag went away. + /// + /// + /// Deleting a tag leaves every host wearing it holding an id that resolves to nothing, and that is the + /// intended outcome: those hosts simply stop drawing that chip. Rewriting N host payloads inside one + /// delete would turn a single user action into N pushes, and each of those is a read-modify-write of a + /// set that can lose a merge — which would leave the vault holding a tag that is deleted and still worn. + /// + public DateTimeOffset? DeletedAtUtc { get; set; } + + /// Who created it. + public Guid CreatedByUserId { get; set; } + + /// Who last modified it. + public Guid UpdatedByUserId { get; set; } +} + /// /// An S3-compatible bucket and the credentials that reach it, as ciphertext. /// diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs index 857b9bc..df208d9 100644 --- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs +++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs @@ -164,11 +164,12 @@ public sealed class KnownHostKeyConfiguration : IEntityTypeConfiguration. /// /// -/// The same shape again, and by now the sameness is the design rather than a coincidence: four item types -/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is the -/// type where a plaintext column would have been most tempting and least defensible — a name here -/// would let the server order a list it never draws, in exchange for telling the operator how every user -/// files their machines. +/// The same shape again, and by now the sameness is the design rather than a coincidence: most item types +/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is a +/// type where plaintext columns would have been tempting and are not defensible — a name here would +/// let the server order a list it never draws, and a parent_id would draw the shape of every user's +/// estate, both in exchange for telling the operator how each of them files their machines. Groups nest +/// through a pointer inside the payload; nothing about that is visible from this table. /// public sealed class HostGroupConfiguration : IEntityTypeConfiguration { @@ -199,6 +200,43 @@ public sealed class HostGroupConfiguration : IEntityTypeConfiguration +/// Maps . +/// +/// +/// again, down to the index names. The one thing worth stating is what +/// is not here: no host_tag join table, because membership is a set inside each host's +/// payload — see — so there is nothing to join and no second table to keep in step. +/// +public sealed class TagConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("tag"); + builder.HasKey(t => t.Id); + + // Client-generated UUIDv7: a tag must be creatable offline, with its id, because the host that + // wears it is created offline too and needs something to point at. + builder.Property(t => t.Id).ValueGeneratedNever(); + builder.UseXminConcurrencyToken(); + + builder.Property(t => t.Payload).IsRequired(); + + builder.HasIndex(t => new { t.VaultId, t.ChangeSequence }); + + builder.HasIndex(t => t.VaultId) + .HasFilter("deleted_at_utc IS NULL") + .HasDatabaseName("ix_tag_vault_live"); + + builder.ToTable(t => t.HasCheckConstraint( + "ck_tag_version", + "version >= 1")); + } +} + /// /// Maps . /// diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs index 14b0bde..e59cc67 100644 --- a/src/DodoSSH.Infrastructure/DodoDbContext.cs +++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs @@ -66,6 +66,9 @@ public class DodoDbContext(DbContextOptions options) : DbContext( /// Host groups, held as ciphertext. public DbSet HostGroups => Set(); + /// Tags hosts can wear, held as ciphertext. + public DbSet Tags => Set(); + /// Saved commands, held as ciphertext. public DbSet Snippets => Set(); diff --git a/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.Designer.cs new file mode 100644 index 0000000..83b0371 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.Designer.cs @@ -0,0 +1,1809 @@ +// +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("20260803072550_AddTagItem")] + partial class AddTagItem + { + /// + 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("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.VaultActivityLogEntry", 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("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_activity_log_entry"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_activity_log_entry_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_activity_log_entry_vault_id_change_sequence"); + + b.ToTable("activity_log_entry", "dodo", t => + { + t.HasCheckConstraint("ck_activity_log_entry_version", "version >= 1"); + }); + }); + + 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.VaultConnectionLogEntry", 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("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_connection_log_entry"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_connection_log_entry_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_connection_log_entry_vault_id_change_sequence"); + + b.ToTable("connection_log_entry", "dodo", t => + { + t.HasCheckConstraint("ck_connection_log_entry_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", 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("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_credential"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_credential_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_credential_vault_id_change_sequence"); + + b.ToTable("credential", "dodo", t => + { + t.HasCheckConstraint("ck_credential_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultHostGroup", 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("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_group"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_host_group_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_host_group_vault_id_change_sequence"); + + b.ToTable("host_group", "dodo", t => + { + t.HasCheckConstraint("ck_host_group_version", "version >= 1"); + }); + }); + + 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.VaultKnownHostKey", 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("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_known_host_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_known_host_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_known_host_key_vault_id_change_sequence"); + + b.ToTable("known_host_key", "dodo", t => + { + t.HasCheckConstraint("ck_known_host_key_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultObjectStore", 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("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_object_store"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_object_store_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_object_store_vault_id_change_sequence"); + + b.ToTable("object_store", "dodo", t => + { + t.HasCheckConstraint("ck_object_store_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSnippet", 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("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_snippet"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_snippet_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_snippet_vault_id_change_sequence"); + + b.ToTable("snippet", "dodo", t => + { + t.HasCheckConstraint("ck_snippet_version", "version >= 1"); + }); + }); + + 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.VaultTag", 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("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_tag"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_tag_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_tag_vault_id_change_sequence"); + + b.ToTable("tag", "dodo", t => + { + t.HasCheckConstraint("ck_tag_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.VaultActivityLogEntry", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_activity_log_entry_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultConnectionLogEntry", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_connection_log_entry_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_credential_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultHostGroup", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_host_group_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + 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.VaultKnownHostKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_known_host_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultObjectStore", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_object_store_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSnippet", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_snippet_vaults_vault_id"); + + 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.VaultTag", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tag_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/20260803072550_AddTagItem.cs b/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.cs new file mode 100644 index 0000000..0c56047 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + /// + public partial class AddTagItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "tag", + 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), + 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_tag", x => x.id); + table.CheckConstraint("ck_tag_version", "version >= 1"); + table.ForeignKey( + name: "fk_tag_vaults_vault_id", + column: x => x.vault_id, + principalSchema: "dodo", + principalTable: "vault", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_tag_vault_id_change_sequence", + schema: "dodo", + table: "tag", + columns: new[] { "vault_id", "change_sequence" }); + + migrationBuilder.CreateIndex( + name: "ix_tag_vault_live", + schema: "dodo", + table: "tag", + column: "vault_id", + filter: "deleted_at_utc IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "tag", + schema: "dodo"); + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs index 502a9f5..b627ea3 100644 --- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs +++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs @@ -1475,6 +1475,87 @@ namespace DodoSSH.Infrastructure.Migrations }); }); + modelBuilder.Entity("DodoSSH.Domain.VaultTag", 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("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_tag"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_tag_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_tag_vault_id_change_sequence"); + + b.ToTable("tag", "dodo", t => + { + t.HasCheckConstraint("ck_tag_version", "version >= 1"); + }); + }); + modelBuilder.Entity("DodoSSH.Domain.Device", b => { b.HasOne("DodoSSH.Domain.UserAccount", "User") @@ -1687,6 +1768,18 @@ namespace DodoSSH.Infrastructure.Migrations b.Navigation("Vault"); }); + modelBuilder.Entity("DodoSSH.Domain.VaultTag", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tag_vaults_vault_id"); + + b.Navigation("Vault"); + }); + modelBuilder.Entity("DodoSSH.Domain.Team", b => { b.Navigation("Memberships"); diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index e8c945f..fedf1e7 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -1026,11 +1026,16 @@ public sealed class SyncEndpointTests(ApiFixture fixture) } [Fact] - public async Task AHostGroupCarryingAParent_IsRejected() + public async Task AHostGroupCarryingAPlaintextParent_IsRejected() { - // Groups are flat. A client that grew a tree would reach for ParentId, and the refusal is what stops - // it storing one — a cycle assembled from two offline re-parents has no repair path, because the - // pointers the server would have to check are inside payloads it cannot read. + // Groups nest, and this refusal survived the change that made them nest — with a different reason. + // It used to mean "there is no such thing as a parent". It now means "the parent is not the server's + // to hold": the pointer lives inside the envelope, and a plaintext column here would hand the + // operator the shape of every user's estate, which is the disclosure ADR 0004 refuses everywhere + // except the one address the relay cannot dial without. + // + // The consequence the old reason worried about is real and is handled elsewhere: the server cannot + // see a cycle assembled from two offline re-parents, so every client walk carries a visited set. var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); @@ -1044,7 +1049,61 @@ public sealed class SyncEndpointTests(ApiFixture fixture) .ShouldHaveSingleItem(); result.Status.ShouldBe(SyncOperationStatus.Invalid); - result.Detail.ShouldNotBeNull().ShouldContain("flat"); + result.Detail.ShouldNotBeNull().ShouldContain("inside its payload"); + } + + [Fact] + public async Task ATag_RoundTripsAsCiphertextWithNoPlaintextAtAll() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var tagId = Guid.CreateVersion7(); + + var pushed = await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest([TagOperation(tagId, expectedVersion: null, envelope: [9, 9])])); + + 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.Tag])); + + var page = await pulled.Content.ReadContractAsync(); + var change = page!.Changes.ShouldHaveSingleItem(); + + change.EntityType.ShouldBe(SyncEntityType.Tag); + change.EntityId.ShouldBe(tagId); + change.Payload.ShouldNotBeNull().Envelope.ShouldBe([9, 9]); + + change.PlaintextFields.ShouldBeNull( + "a tag name says what a machine is, and one tag spans twenty of them — a column here would be a " + + "labelled map of the estate, for a sort this server never performs"); + } + + [Fact] + public async Task ATagCarryingTheHostItIsOn_IsRejected() + { + // RelatedId is where a join row would put the host, and SyncEntityType.HostTag reserves the slot for + // exactly that join. This design keeps membership in the host's payload instead, so a client + // reaching for the field would be writing the host-to-tag graph into the clear one row at a time. + // SyncPlaintextFields is frozen, so refusing it here is the only place the decision can be enforced. + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var operation = TagOperation(Guid.CreateVersion7(), null, [9]) + with + { PlaintextFields = new SyncPlaintextFields(RelatedId: Guid.CreateVersion7()) }; + + 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.ShouldNotBeNull().ShouldContain("inside theirs"); } [Fact] @@ -1291,8 +1350,9 @@ public sealed class SyncEndpointTests(ApiFixture fixture) PlaintextFields: null); /// - /// Narrow for the same reason as the two above, and the field it most conspicuously does not carry is the - /// one named after the type: a group's name is inside the envelope, and so is its membership. + /// Narrow for the same reason as the two above, and the fields it most conspicuously does not carry are + /// the two named after the type: a group's name is inside the envelope, and so are its membership and + /// its parent. /// private static SyncPushOperation HostGroupOperation( Guid entityId, @@ -1307,6 +1367,19 @@ public sealed class SyncEndpointTests(ApiFixture fixture) Payload(envelope), PlaintextFields: null); + private static SyncPushOperation TagOperation( + Guid entityId, + int? expectedVersion, + byte[] envelope) => + new( + Guid.CreateVersion7(), + SyncEntityType.Tag, + entityId, + SyncOperation.Upsert, + expectedVersion, + Payload(envelope), + PlaintextFields: null); + private static SyncPushOperation SnippetOperation( Guid entityId, int? expectedVersion, diff --git a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs index cfbd978..6416e3c 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs @@ -1,6 +1,8 @@ namespace DodoSSH.Client.Domain.Tests; -/// Builds hosts for the suites, so each test varies only what it is about. +/// +/// Builds hosts, and the groups they are filed under, so each test varies only what it is about. +/// internal static class HostFactory { internal static Guid Bastion { get; } = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e01"); @@ -13,10 +15,21 @@ internal static class HostFactory /// A group id, for the hosts that are filed under one. internal static Guid Production { get; } = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e04"); + /// A tag id, for the hosts that wear one. + internal static Guid Pci { get; } = Guid.Parse("0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08"); + + /// A second tag id, for the tests about two people tagging one host. + internal static Guid EuWest { get; } = Guid.Parse("0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09"); + + /// + /// defaults to an explicit 22 rather than to null, so a test that says nothing + /// about ports gets the host shape that existed before inheritance did — which is what nearly every + /// suite here is still about. Passing is how a test asks for the new one. + /// internal static HostSecret Host( string label = "prod-db", string hostname = "db.internal", - int port = 22, + int? port = 22, string? username = "deploy", string? notes = null, Guid[]? jumps = null, @@ -24,7 +37,9 @@ internal static class HostFactory bool relayEnabled = false, Guid? sshKeyId = null, Guid? credentialId = null, - Guid? groupId = null) => + bool? asksForPassword = null, + Guid? groupId = null, + Guid[]? tags = null) => new() { Label = label, @@ -39,6 +54,33 @@ internal static class HostFactory RelayEnabled = relayEnabled, SshKeyId = sshKeyId, CredentialId = credentialId, + AsksForPassword = asksForPassword, GroupId = groupId, + TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags), + }; + + /// + /// A group, flat and defaulting nothing unless the test says otherwise. + /// + /// + /// The defaults of this builder are the shape a group had before it could nest or default anything, + /// which is what most suites still want: a heading with a name. A test that passes nothing here is + /// asserting about the old shape on purpose. + /// + internal static HostGroupSecret Group( + string label = "production", + Guid? parentId = null, + int? defaultPort = null, + string? defaultUsername = null, + Guid? defaultSshKeyId = null, + Guid? defaultCredentialId = null) => + new() + { + Label = label, + ParentId = parentId, + DefaultPort = defaultPort, + DefaultUsername = defaultUsername, + DefaultSshKeyId = defaultSshKeyId, + DefaultCredentialId = defaultCredentialId, }; } diff --git a/tests/DodoSSH.Client.Domain.Tests/HostGroupSecretTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostGroupSecretTests.cs index 39f7072..99b1c54 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostGroupSecretTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostGroupSecretTests.cs @@ -1,42 +1,141 @@ using System.Text; +using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// -/// A group: one name, and the reasons it is only that. +/// A group: a name, a parent, and the four things hosts under it fall back to. /// /// -/// There is very little behaviour here to test, which is itself the design — every field that was considered -/// and left out (a parent, a member list) was left out because of what it would do to the merge. What these -/// tests pin is that the envelope round-trips, that a nameless group cannot be stored, and that renaming the -/// same group on two machines is reported rather than silently resolved. +/// +/// A member list is still absent and still for the reason it always was — membership is a pointer on each +/// host, so two people filing two machines into one group is two writes to two items. A parent is present, +/// and it was not: see for why nesting stopped being worth refusing once the +/// defaults made the chain something the connect path had to walk anyway. +/// +/// +/// What these pin is that the envelope round-trips, that a nameless or unstorable group cannot be stored, +/// that a group carrying none of the new fields still encodes at version 1 byte for byte, and that every +/// field is actually consulted by the merge rather than quietly deferring to the server forever. +/// /// public sealed class HostGroupSecretTests { + private static Guid Parent { get; } = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05"); + + private static Guid TeamCredential { get; } = Guid.Parse("0192f0c8-6666-7c3d-8e4f-5a6b7c8d9e06"); + [Fact] - public void AGroup_RoundTrips() + public void AFlatGroupWithNoDefaults_RoundTripsAtTheVersionItAlwaysHad() { - var group = new HostGroupSecret { Label = "production" }; + var group = Group(); HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.Group.ShouldBe(group); - document.SchemaVersion.ShouldBe(HostGroupSecretCodec.CurrentSchemaVersion); + document.SchemaVersion.ShouldBe(HostGroupSecretCodec.BaseSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } + [Fact] + public void ANestedGroupCarryingEveryDefault_RoundTrips() + { + var group = Group( + parentId: Parent, + defaultPort: 2222, + defaultUsername: "deploy", + defaultCredentialId: TeamCredential); + + HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document) + .ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.Group.ShouldBe(group); + document.SchemaVersion.ShouldBe(HostGroupSecretCodec.ParentAndDefaultsSchemaVersion); + document.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void AddingTheParentAndDefaults_DidNotChangeTheBytesOfAGroupWithoutThem() + { + // Pinned against a literal rather than against the codec, because the claim is about history: every + // group already in every vault must re-encode to what it encoded before any of these fields existed, + // or the first sync after an upgrade would push every group as changed. Byte-for-byte, so a new + // field that serialised ahead of the name — or a null that serialised as null — would fail here. + // + // The version in this literal is the other half of the claim. This codec used to stamp + // CurrentSchemaVersion unconditionally, and had that survived, a flat group would now be written at + // 2 and read as uneditable on every machine that had not upgraded. + var bytes = HostGroupSecretCodec.Encode(Group()); + + Encoding.UTF8.GetString(bytes).ShouldBe("""{"schemaVersion":1,"label":"production"}"""); + } + + [Theory] + [MemberData(nameof(GroupsCarryingOneNewField))] + public void AGroupCarryingAnyNewField_IsWrittenAtTheVersionThatIntroducedThem(HostGroupSecret group) + { + HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document) + .ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.SchemaVersion.ShouldBe( + HostGroupSecretCodec.ParentAndDefaultsSchemaVersion, + "a version that cannot represent the field just written makes an older client decode the " + + "group as editable and drop that field on the next save"); + } + + public static TheoryData GroupsCarryingOneNewField() => + [ + Group(parentId: Parent), + Group(defaultPort: 2222), + Group(defaultUsername: "deploy"), + Group(defaultSshKeyId: DeployKey), + Group(defaultCredentialId: TeamCredential), + ]; + [Theory] [InlineData("")] [InlineData(" ")] public void AGroupWithNoName_IsRefused(string label) { - new HostGroupSecret { Label = label }.TryValidate(out var reason).ShouldBeFalse(); + Group(label: label).TryValidate(out var reason).ShouldBeFalse(); reason.ShouldNotBeNull(); } + [Fact] + public void TryValidate_RejectsWhatCannotBeStored() + { + Group(parentId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); + Group(defaultPort: 0).TryValidate(out _).ShouldBeFalse(); + Group(defaultPort: 65536).TryValidate(out _).ShouldBeFalse(); + Group(defaultSshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); + Group(defaultCredentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); + + // Null is the absence of each of these, and the absence is always storable — it is what every group + // in every vault written before this build carries. + Group().TryValidate(out _).ShouldBeTrue(); + } + + [Fact] + public void AGroupDefaultsOneWay_NotTwo() + { + // The same exclusion a host is held to, for the same reason: a group naming both leaves "what do + // hosts under this authenticate with?" without a single answer. Necessary but not sufficient — a + // host naming a credential under a group naming a key is two valid records, so the resolver enforces + // it again across the chain. + var both = Group(defaultSshKeyId: DeployKey, defaultCredentialId: TeamCredential); + + both.TryValidate(out var reason).ShouldBeFalse(); + reason.ShouldNotBeNull().ShouldContain("not both"); + + Group(defaultSshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue(); + Group(defaultCredentialId: TeamCredential).TryValidate(out _).ShouldBeTrue(); + } + [Fact] public void AGroupWrittenByANewerClient_IsReadableButNotWritableHere() { @@ -64,10 +163,59 @@ public sealed class HostGroupSecretTests document.ShouldBeNull(); } + [Fact] + public void AnUnstorablePayload_FailsToDecodeRatherThanProducingAGroupThatCannotBeSaved() + { + // A default port outside the range cannot have been written by this build, so it is either a bug in + // some client or a corrupted write. Decoding it would produce a group the editor could open and + // never save, with nothing on screen to say which field was the problem. + var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":2,"label":"production","defaultPort":0}"""); + + HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeFalse(); + + document.ShouldBeNull(); + } + + [Fact] + public void EveryScalarField_IsRoutedThroughAMerge() + { + // A field added to HostGroupSecret but forgotten in the merge would silently revert to the remote + // value forever. Changing each one only locally proves each is actually consulted. + var ancestor = Group(); + + var local = ancestor with + { + Label = "prod", + ParentId = Parent, + DefaultPort = 2222, + DefaultUsername = "deploy", + DefaultCredentialId = TeamCredential, + }; + + var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor); + + result.Merged.ShouldBe(local); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void TheKeyFieldTheExclusionKeepsOutOfTheOtherTest_IsAlsoRoutedThroughAMerge() + { + // DefaultSshKeyId cannot appear beside DefaultCredentialId in one valid group, so it gets its own + // pass rather than being the one field the guard above silently skips. + var ancestor = Group(); + var local = ancestor with { DefaultSshKeyId = DeployKey }; + + var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor); + + result.Merged.ShouldBe(local); + result.HasConflicts.ShouldBeFalse(); + } + [Fact] public void TwoDifferentRenames_AreReportedWithBothNames() { - var ancestor = new HostGroupSecret { Label = "production" }; + var ancestor = Group(); var result = HostGroupSecretMerge.Merge( ancestor, @@ -83,6 +231,64 @@ public sealed class HostGroupSecretTests conflict.Discarded.ShouldBe("prod"); } + [Fact] + public void TwoDifferentReparentings_AreReportedWithBothParents() + { + // The clash that admits a cycle. This merge sees one group against one group, so it cannot know the + // pair it is half of — it resolves, reports, and leaves the containment to the resolver's visited + // set. What it must not do is resolve silently. + var other = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e07"); + + var ancestor = Group(); + var result = HostGroupSecretMerge.Merge( + ancestor, + ancestor with { ParentId = Parent }, + ancestor with { ParentId = other }); + + result.Merged.ParentId.ShouldBe(other); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + + conflict.Field.ShouldBe(nameof(HostGroupSecret.ParentId)); + conflict.Kept.ShouldBe(other.ToString()); + conflict.Discarded.ShouldBe(Parent.ToString()); + } + + [Fact] + public void ADefaultClearedLocally_IsNotResurrectedByTheOtherSide() + { + // Null is a value here, not an absence: a group deliberately put back to no default user must not + // silently regain one because the server's copy still names it. + var ancestor = Group(defaultUsername: "deploy"); + var local = ancestor with { DefaultUsername = null }; + + var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor); + + result.Merged.DefaultUsername.ShouldBeNull(); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void ADefaultClearedOnOneSideAndChangedOnTheOther_NamesTheAbsenceInTheConflict() + { + // The formatter has to run for the null side too. Short-circuiting on null would print an empty + // string where the conflict log needs to say that what lost was the removal of the default. + var ancestor = Group(defaultPort: 22); + + var result = HostGroupSecretMerge.Merge( + ancestor, + ancestor with { DefaultPort = null }, + ancestor with { DefaultPort = 2222 }); + + result.Merged.DefaultPort.ShouldBe(2222); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + + conflict.Field.ShouldBe(nameof(HostGroupSecret.DefaultPort)); + conflict.Kept.ShouldBe("2222"); + conflict.Discarded.ShouldBe("no default port"); + } + /// /// The case that would collide if membership were held on the group instead of on each host: two people /// filing two different machines into one group at the same time. It cannot reach the merge at all, @@ -91,7 +297,7 @@ public sealed class HostGroupSecretTests [Fact] public void FilingHostsIntoAGroup_DoesNotTouchTheGroup() { - var ancestor = new HostGroupSecret { Label = "production" }; + var ancestor = Group(); var result = HostGroupSecretMerge.Merge(ancestor, ancestor, ancestor); diff --git a/tests/DodoSSH.Client.Domain.Tests/HostInheritanceTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostInheritanceTests.cs new file mode 100644 index 0000000..033de11 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/HostInheritanceTests.cs @@ -0,0 +1,257 @@ +using static DodoSSH.Client.Domain.Tests.HostFactory; + +namespace DodoSSH.Client.Domain.Tests; + +/// +/// Walking a host's group chain for the values it did not state itself. +/// +/// +/// +/// This is the code the connect path runs, so its failures are not cosmetic. A wrong port dials the wrong +/// machine or nothing at all; a wrong binding sends a password to a host set up for key-only access; and a +/// walk that does not terminate is a shell that never opens, which is the one failure a user cannot even +/// describe. +/// +/// +/// The cycle tests are the reason the walk carries a visited set at all. A cycle cannot be created through +/// the editor and cannot be seen by the merge, which resolves one group against one group, or by the +/// server, which cannot read the payload — so it arrives assembled from two offline re-parents or not at +/// all. What these pin is that it degrades to a group reading as a root rather than hanging. +/// +/// +public sealed class HostInheritanceTests +{ + private static Guid Estate { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"); + + private static Guid Region { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"); + + private static Guid TeamCredential { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"); + + [Fact] + public void AHostThatStatesEverything_InheritsNothing() + { + var host = Host(port: 2222, username: "deploy", sshKeyId: DeployKey, groupId: Production); + + var resolved = HostInheritance.Resolve( + host, + Groups((Production, Group(defaultPort: 9999, defaultUsername: "root")))); + + resolved.Port.Value.ShouldBe(2222); + resolved.Port.IsInherited.ShouldBeFalse(); + resolved.Username.Value.ShouldBe("deploy"); + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.SshKey); + resolved.Binding.EntityId.ShouldBe(DeployKey); + resolved.Binding.IsInherited.ShouldBeFalse(); + } + + [Fact] + public void AHostThatStatesNothing_TakesItsGroupsValuesAndSaysWhichGroup() + { + // The second half matters as much as the first: the editor draws an inherited value as a + // placeholder behind an empty box, so it has to know the value came from somewhere else. + var host = Host(port: null, username: null, groupId: Production); + + var resolved = HostInheritance.Resolve( + host, + Groups((Production, Group( + defaultPort: 2222, + defaultUsername: "deploy", + defaultCredentialId: TeamCredential)))); + + resolved.Port.Value.ShouldBe(2222); + resolved.Port.FromGroupId.ShouldBe(Production); + resolved.Username.Value.ShouldBe("deploy"); + resolved.Username.FromGroupId.ShouldBe(Production); + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential); + resolved.Binding.EntityId.ShouldBe(TeamCredential); + resolved.Binding.FromGroupId.ShouldBe(Production); + } + + [Fact] + public void AHostWithNoGroupAndNothingStated_FallsBackToTwentyTwoAndATypedPassword() + { + // Every host stored before any of this existed. Nothing changed underneath them: naming neither a + // key nor a credential resolved to a typed password then, and resolves to one now. + var resolved = HostInheritance.Resolve(Host(port: null, username: null), Groups()); + + resolved.Port.Value.ShouldBe(HostSecret.DefaultPort); + resolved.Port.IsInherited.ShouldBeFalse(); + resolved.Username.Value.ShouldBeNull(); + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword); + resolved.Binding.EntityId.ShouldBeNull(); + } + + [Fact] + public void TheNearestGroupThatStatesAValue_Wins() + { + var host = Host(port: null, username: null, groupId: Production); + + var resolved = HostInheritance.Resolve( + host, + Groups( + (Production, Group(parentId: Region, defaultPort: 2222)), + (Region, Group(parentId: Estate, defaultPort: 9999, defaultUsername: "deploy")), + (Estate, Group(defaultUsername: "root")))); + + resolved.Port.Value.ShouldBe(2222); + resolved.Port.FromGroupId.ShouldBe(Production); + + // Each field is resolved on its own, so a group that answers one question does not stop the walk + // for the others. + resolved.Username.Value.ShouldBe("deploy"); + resolved.Username.FromGroupId.ShouldBe(Region); + } + + [Fact] + public void AHostWithAnEmptyUsername_HasNoUsernameRatherThanTheGroups() + { + // The three states in one nullable string. Null used to mean "no username", which the connect path + // refuses; it now means "ask the group", so the refusal has to stay reachable or a host under a + // group could never opt out of the group's user. + var resolved = HostInheritance.Resolve( + Host(username: string.Empty, groupId: Production), + Groups((Production, Group(defaultUsername: "deploy")))); + + resolved.Username.Value.ShouldBe(string.Empty); + resolved.Username.IsInherited.ShouldBeFalse(); + } + + [Fact] + public void AHostPinnedToATypedPassword_DoesNotPickUpItsGroupsKey() + { + // The failure worth ruling out above all others in this file. A host deliberately put back on a + // typed password must not silently start authenticating with the fleet's key because somebody set a + // default on the group above it. + var resolved = HostInheritance.Resolve( + Host(asksForPassword: true, groupId: Production), + Groups((Production, Group(defaultSshKeyId: DeployKey)))); + + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword); + resolved.Binding.EntityId.ShouldBeNull(); + resolved.Binding.IsInherited.ShouldBeFalse(); + } + + [Fact] + public void AHostNamingACredentialUnderAGroupNamingAKey_ResolvesToOneBindingAndItIsTheHosts() + { + // Two individually valid records that per-record validation cannot catch, because neither one is + // wrong on its own. The exclusion is enforced again here, and the nearer statement wins. + var resolved = HostInheritance.Resolve( + Host(credentialId: TeamCredential, groupId: Production), + Groups((Production, Group(defaultSshKeyId: DeployKey)))); + + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential); + resolved.Binding.EntityId.ShouldBe(TeamCredential); + } + + [Fact] + public void ANearerGroupsBinding_BeatsAFurtherOnesEvenWhenTheyDiffer() + { + var resolved = HostInheritance.Resolve( + Host(port: null, groupId: Production), + Groups( + (Production, Group(parentId: Estate, defaultCredentialId: TeamCredential)), + (Estate, Group(defaultSshKeyId: DeployKey)))); + + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential); + resolved.Binding.FromGroupId.ShouldBe(Production); + } + + [Fact] + public void AGroupDeletedOnAnotherMachine_LeavesTheHostUngroupedRatherThanFailing() + { + // A dangling id is the ordinary outcome of a delete, not a corruption: deleting a group does not + // rewrite the hosts that named it, deliberately. See HostGroupRepository. + var resolved = HostInheritance.Resolve(Host(port: null, username: null, groupId: Production), Groups()); + + resolved.Port.Value.ShouldBe(HostSecret.DefaultPort); + resolved.Username.Value.ShouldBeNull(); + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword); + } + + [Fact] + public void AGroupWhoseParentWasDeleted_StopsAtThatGroupRatherThanFailing() + { + var resolved = HostInheritance.Resolve( + Host(port: null, username: null, groupId: Production), + Groups((Production, Group(parentId: Estate, defaultPort: 2222)))); + + resolved.Port.Value.ShouldBe(2222); + resolved.Username.Value.ShouldBeNull(); + } + + [Fact] + public void ATwoGroupCycle_ResolvesRatherThanHanging() + { + // The state the old "groups are flat" decision existed to prevent, arriving anyway: two clients each + // re-parenting A under B and B under A while offline. The merge sees one group against one group and + // the server sees ciphertext, so nothing upstream can refuse the pair — which is why the answer is a + // walk that terminates rather than a state that cannot occur. + var resolved = HostInheritance.Resolve( + Host(port: null, username: null, groupId: Production), + Groups( + (Production, Group(parentId: Region, defaultPort: 2222)), + (Region, Group(parentId: Production, defaultUsername: "deploy")))); + + // Both groups are still read once, because the repeat is what stops the walk rather than the loop + // being detected up front. What must not happen is a third visit. + resolved.Port.Value.ShouldBe(2222); + resolved.Username.Value.ShouldBe("deploy"); + } + + [Fact] + public void AGroupThatIsItsOwnParent_ReadsAsARoot() + { + // Not creatable through the editor and not knowable to HostGroupSecret.TryValidate, which sees the + // payload and not the item id. Contained here instead, in the same visited set that has to contain + // a longer cycle anyway. + var chain = HostInheritance + .Chain(Production, Groups((Production, Group(parentId: Production)))) + .ToList(); + + chain.Count.ShouldBe(1); + chain[0].Id.ShouldBe(Production); + } + + [Fact] + public void ACycleAboveAHost_StillResolvesEveryFieldItCanReach() + { + // The degradation the design accepts: defaults past the repeat are unresolved, everything before it + // is not, and clearing the parent in the editor is the repair. What it must never be is a connect + // that never returns. + var resolved = HostInheritance.Resolve( + Host(port: null, username: null, groupId: Production), + Groups( + (Production, Group(parentId: Region)), + (Region, Group(parentId: Production)))); + + resolved.Port.Value.ShouldBe(HostSecret.DefaultPort); + resolved.Username.Value.ShouldBeNull(); + resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword); + } + + [Fact] + public void TheChain_YieldsNearestFirstAndStopsAtTheRoot() + { + var chain = HostInheritance + .Chain( + Production, + Groups( + (Production, Group(label: "production", parentId: Region)), + (Region, Group(label: "eu-west", parentId: Estate)), + (Estate, Group(label: "estate")))) + .ToList(); + + chain.Select(entry => entry.Group.Label).ShouldBe(["production", "eu-west", "estate"]); + } + + [Fact] + public void TheChainOfAnUngroupedHost_IsEmpty() + { + HostInheritance.Chain(null, Groups()).ShouldBeEmpty(); + } + + private static Dictionary Groups( + params (Guid Id, HostGroupSecret Group)[] groups) => + groups.ToDictionary(entry => entry.Id, entry => entry.Group); +} diff --git a/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs index e152520..3fbe358 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs @@ -16,9 +16,10 @@ public sealed class HostSecretCodecTests { /// /// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may - /// carry a key or a credential and never both. This one carries the credential, because that is the newer - /// of the two, plus a group — which is orthogonal to both and is what makes this host reach the highest - /// schema version a valid host can. + /// carry a key or a credential and never both, and neither may sit beside AsksForPassword. This + /// one carries the credential, because that is the newer of the two, plus a group and a pair of tags — + /// which are orthogonal to the binding and are what make this host reach the highest schema version a + /// valid host can. /// [Fact] public void AFullHost_RoundTrips() @@ -35,7 +36,8 @@ public sealed class HostSecretCodecTests options: [("ServerAliveInterval", "30"), ("Compression", "yes")], relayEnabled: true, credentialId: credentialId, - groupId: Production); + groupId: Production, + tags: [Pci, EuWest]); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); @@ -146,6 +148,149 @@ public sealed class HostSecretCodecTests document.Host.ShouldBe(host); } + [Fact] + public void AHostThatInheritsItsPort_IsWrittenAtTheVersionThatIntroducedInheritance() + { + HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var document) + .ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.SchemaVersion.ShouldBe( + HostSecretCodec.PortInheritSchemaVersion, + "a host stamped at 4 with its port omitted is not read-only on an older build — it is " + + "undecodable there, because int Port reads 0 and TryValidate refuses it"); + + document.Host.Port.ShouldBeNull(); + } + + [Fact] + public void AHostPinnedToATypedPassword_IsWrittenAtTheSameVersionAsAnInheritedPort() + { + // Both halves of inheritance share a version because they arrive together and answer the same + // question: what a host says when it declines to take its group's answer. + HostSecretCodec + .TryDecode(HostSecretCodec.Encode(Host(asksForPassword: true)), out var document) + .ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.SchemaVersion.ShouldBe(HostSecretCodec.PortInheritSchemaVersion); + document.Host.AsksForPassword.ShouldBe(true); + } + + [Fact] + public void AHostWearingTags_IsWrittenAtTheVersionThatIntroducedThem() + { + HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var document) + .ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.SchemaVersion.ShouldBe(HostSecretCodec.TagIdsSchemaVersion); + document.Host.TagIds.ShouldBe(TagSet.Create([Pci])); + } + + [Fact] + public void ATaggedHostThatPinsItsPort_IsNotDraggedOntoTheInheritanceVersionOrBelowIt() + { + // The maximum, from both directions. Tags are independent of inheritance, so a host that only wears + // one must not be written at 5 — and a host that only inherits must not be written at 6, which would + // make it undecodable on a build that could have read it. + HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var tagged) + .ShouldBeTrue(); + + HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var inheriting) + .ShouldBeTrue(); + + tagged.ShouldNotBeNull().SchemaVersion + .ShouldBeGreaterThan(HostSecretCodec.PortInheritSchemaVersion); + + inheriting.ShouldNotBeNull().SchemaVersion + .ShouldBeLessThan(HostSecretCodec.TagIdsSchemaVersion); + } + + [Fact] + public void AHostWithAnEmptyTagSet_IsWrittenAtTheVersionItWouldHaveHadWithout() + { + // Wearing no tags is not using the feature. If an empty set bumped the version, adding tags would + // have made every host in every vault read-only on every machine that had not upgraded. + HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [])), out var document) + .ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion); + } + + [Fact] + public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither() + { + // The companion to the pin below, and the one that matters most for these two fields: a set that + // serialised as [] and a flag that serialised as false would both land in every host in every vault, + // and the first sync after the upgrade would push all of them as changed. + var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, tags: [])); + + Encoding.UTF8.GetString(bytes).ShouldBe( + """ + {"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false} + """); + } + + [Fact] + public void AHostThatInheritsItsPort_OmitsTheKeyRatherThanWritingANull() + { + // The companion pin the old one could not be edited into. What an inheriting host must produce is + // the absence of "port", not "port":null and not "port":22 — the first would decode as 0 on any + // build, and the second is what inheritance exists to stop writing. + var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, port: null)); + + Encoding.UTF8.GetString(bytes).ShouldBe( + """ + {"schemaVersion":5,"label":"prod-db","hostname":"db.internal","jumpHostIds":[],"options":{},"relayEnabled":false} + """); + } + + [Fact] + public void AHostWearingTags_WritesThemLastAndSorted() + { + // Last, so every field that existed before them keeps its bytes; sorted, so two users who tapped the + // same two chips in opposite orders produce one value and nothing to push. + var bytes = HostSecretCodec.Encode( + Host(username: null, notes: null, tags: [EuWest, Pci])); + + Encoding.UTF8.GetString(bytes).ShouldBe( + """ + {"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08","0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09"]} + """); + } + + [Fact] + public void APayloadSayingItAsksForNoPassword_DecodesAsHavingSaidNothing() + { + // False and null mean the same thing — take the group's binding — so only one may reach the record. + // Two spellings of one state is a difference the merge would report as a change nobody made. + var payload = Encoding.UTF8.GetBytes( + """ + {"schemaVersion":5,"label":"prod-db","hostname":"db.internal","port":22,"asksForPassword":false} + """); + + HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + + document.ShouldNotBeNull().Host.AsksForPassword.ShouldBeNull(); + } + + [Fact] + public void APayloadRepeatingATag_DecodesAsTheSetItMeant() + { + // Written by some other client, and it must not compare unequal to the same set written once — or + // the engine would push this host as changed on every pass for ever. + var payload = Encoding.UTF8.GetBytes( + $$""" + {"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["{{Pci}}","{{Pci}}"]} + """); + + HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + + document.ShouldNotBeNull().Host.TagIds.ShouldBe(TagSet.Create([Pci])); + } + [Fact] public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne() { diff --git a/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs index 22aa12b..e2c85e0 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs @@ -56,6 +56,8 @@ public sealed class HostSecretMergeTests Options = HostOptions.Create([new HostOption("Compression", "yes")]), RelayEnabled = true, SshKeyId = DeployKey, + GroupId = Production, + TagIds = TagSet.Create([Pci]), }; var result = HostSecretMerge.Merge(ancestor, local, ancestor); @@ -64,6 +66,122 @@ public sealed class HostSecretMergeTests result.HasConflicts.ShouldBeFalse(); } + [Fact] + public void TheFieldsTheExclusionsKeepOutOfTheGuardAbove_AreAlsoRoutedThroughAMerge() + { + // AsksForPassword cannot sit beside SshKeyId in one valid host, and a null Port cannot sit beside + // an explicit one — so both get their own pass rather than being the fields the guard silently + // skips. A forgotten one here means a host put back on a typed password, or set to take its group's + // port, quietly reverting to the server's copy for ever. + var ancestor = Host(); + + var local = ancestor with { Port = null, AsksForPassword = true }; + + var result = HostSecretMerge.Merge(ancestor, local, ancestor); + + result.Merged.ShouldBe(local); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void TwoPeopleAddingDifferentTagsToOneHost_BothKeepTheirs() + { + // The single most visible difference between a field-level merge and last-writer-wins, and the + // reason TagIds merges per tag rather than as a whole value. A whole-value merge would take one + // side's set entire and drop the other's. + var ancestor = Host(); + + var result = HostSecretMerge.Merge( + ancestor, + ancestor with { TagIds = TagSet.Create([Pci]) }, + ancestor with { TagIds = TagSet.Create([EuWest]) }); + + result.Merged.TagIds.ShouldBe(TagSet.Create([Pci, EuWest])); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void OneSideRemovingATagWhileTheOtherAddsAnother_KeepsBothDecisions() + { + // Each tag is resolved on its own, so a removal on one side and an addition on the other are two + // independent answers rather than two versions of one. A whole-value merge would have to pick. + var ancestor = Host(tags: [Pci]); + + var result = HostSecretMerge.Merge( + ancestor, + ancestor with { TagIds = TagSet.Empty }, + ancestor with { TagIds = TagSet.Create([Pci, EuWest]) }); + + result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest])); + result.HasConflicts.ShouldBeFalse(); + } + + [Fact] + public void ATagRemovedOnBothSides_IsNotResurrected() + { + var ancestor = Host(tags: [Pci, EuWest]); + var untagged = ancestor with { TagIds = TagSet.Create([EuWest]) }; + + var result = HostSecretMerge.Merge(ancestor, untagged, untagged); + + result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest])); + result.HasConflicts.ShouldBeFalse(); + } + + /// + /// The property that makes a tag set the one field on a host which can never ask the user anything. A + /// tag is present or absent, so a key cannot hold two values, so the "both sides moved differently" + /// branch of the keyed merge is unreachable — see HostSecretMerge.MergeTags. Stated as a table + /// over every arrangement of one tag, because the claim is about the whole matrix rather than about any + /// one row of it. + /// + [Theory] + [InlineData(true, true, true)] + [InlineData(true, true, false)] + [InlineData(true, false, true)] + [InlineData(true, false, false)] + [InlineData(false, true, true)] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + [InlineData(false, false, false)] + public void NoArrangementOfOneTag_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote) + { + var result = HostSecretMerge.Merge( + Host(tags: Wearing(inAncestor)), + Host(tags: Wearing(inLocal)), + Host(tags: Wearing(inRemote))); + + result.HasConflicts.ShouldBeFalse(); + + // And the outcome is the one a set should give: a side that moved gets its way, because the other + // one did not move. + result.Merged.TagIds.Contains(Pci).ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote); + } + + private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : []; + + [Fact] + public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict() + { + // The formatter has to run for the null side, and null here is not an absence — it is the decision + // to take the group's port. A conflict log printing an empty string in its place would leave the + // user unable to tell which of the two decisions was dropped. + var ancestor = Host(port: 22); + + var result = HostSecretMerge.Merge( + ancestor, + ancestor with { Port = null }, + ancestor with { Port = 2222 }); + + result.Merged.Port.ShouldBe(2222); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + + conflict.Field.ShouldBe(nameof(HostSecret.Port)); + conflict.Kept.ShouldBe("2222"); + conflict.Discarded.ShouldBe("the group's port"); + } + [Fact] public void ARemovedKeyBinding_IsNotResurrectedByTheOtherSide() { diff --git a/tests/DodoSSH.Client.Domain.Tests/TagSecretTests.cs b/tests/DodoSSH.Client.Domain.Tests/TagSecretTests.cs new file mode 100644 index 0000000..dc25a05 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/TagSecretTests.cs @@ -0,0 +1,117 @@ +using System.Text; + +namespace DodoSSH.Client.Domain.Tests; + +/// +/// A tag: one name, and the reasons it is only that. +/// +/// +/// +/// There is very little behaviour here, which is the design — a tag exists to have an identity, so that +/// renaming it is one write instead of twenty. What these pin is that the envelope round-trips, that a +/// nameless tag cannot be stored, and that renaming the same tag on two machines is reported rather than +/// silently resolved. +/// +/// +/// The absent test worth naming: nothing here refuses a second tag called the same thing. Two people +/// creating "staging" offline is how it happens, and refusing the loser would mean discarding the tags a +/// colleague had already put on their hosts. See . +/// +/// +public sealed class TagSecretTests +{ + [Fact] + public void ATag_RoundTrips() + { + var tag = new TagSecret { Label = "pci" }; + + TagSecretCodec.TryDecode(TagSecretCodec.Encode(tag), out var document).ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.Tag.ShouldBe(tag); + document.SchemaVersion.ShouldBe(TagSecretCodec.CurrentSchemaVersion); + document.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void ATag_EncodesAtTheBaseVersionWithNothingBesideItsName() + { + // Pinned as a literal, so the first field this type ever grows has to be appended and has to be + // omitted when null — the two properties that keep every tag already in every vault re-encoding to + // the bytes it was stored with, rather than looking like a change on the first sync after an upgrade. + Encoding.UTF8.GetString(TagSecretCodec.Encode(new TagSecret { Label = "pci" })) + .ShouldBe("""{"schemaVersion":1,"label":"pci"}"""); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ATagWithNoName_IsRefused(string label) + { + new TagSecret { Label = label }.TryValidate(out var reason).ShouldBeFalse(); + + reason.ShouldNotBeNull(); + } + + [Fact] + public void ATagWrittenByANewerClient_IsReadableButNotWritableHere() + { + var payload = Encoding.UTF8.GetBytes( + """ + {"schemaVersion":99,"label":"pci","colour":"a field this build has never heard of"} + """); + + TagSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.Tag.Label.ShouldBe("pci"); + document.IsReadOnly.ShouldBeTrue(); + } + + [Fact] + public void AnUnnamedPayload_FailsToDecodeRatherThanProducingABlankTag() + { + // Failing closed, as every codec in this folder does: a nameless tag is an empty chip beside a host, + // filtering a set nobody can name. + var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":1}"""); + + TagSecretCodec.TryDecode(payload, out var document).ShouldBeFalse(); + + document.ShouldBeNull(); + } + + [Fact] + public void TwoDifferentRenames_AreReportedWithBothNames() + { + var ancestor = new TagSecret { Label = "pci" }; + + var result = TagSecretMerge.Merge( + ancestor, + ancestor with { Label = "pci-dss" }, + ancestor with { Label = "in-scope" }); + + result.Merged.Label.ShouldBe("in-scope"); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + + conflict.Field.ShouldBe(nameof(TagSecret.Label)); + conflict.Kept.ShouldBe("in-scope"); + conflict.Discarded.ShouldBe("pci-dss"); + } + + /// + /// The case that would collide if membership were held on the tag instead of on each host: two people + /// putting one tag on two different machines at the same time. It cannot reach the merge at all, because + /// neither of those actions writes to this item. + /// + [Fact] + public void TaggingHosts_DoesNotTouchTheTag() + { + var ancestor = new TagSecret { Label = "pci" }; + + var result = TagSecretMerge.Merge(ancestor, ancestor, ancestor); + + result.HasConflicts.ShouldBeFalse(); + result.Merged.ShouldBe(ancestor); + } +} diff --git a/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs b/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs index 7e96bc9..32ede4a 100644 --- a/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs @@ -149,18 +149,79 @@ public sealed class ValueSemanticsTests (copy with { Notes = host.Notes }).ShouldBe(host); } + [Fact] + public void ATagSet_ComparesByContentsAndNotByOrder() + { + // The half that differs from a jump chain, and the reason it is a separate type. A route reordered + // is a different route; a tag list reordered is the same host, so two users who tapped the same two + // chips in opposite orders must produce one value and nothing to push. + TagSet.Create([Pci, EuWest]).Equals(TagSet.Create([EuWest, Pci])).ShouldBeTrue(); + (TagSet.Create([Pci, EuWest]) == TagSet.Create([EuWest, Pci])).ShouldBeTrue(); + + TagSet.Create([Pci, EuWest]).GetHashCode() + .ShouldBe(TagSet.Create([EuWest, Pci]).GetHashCode()); + + TagSet.Create([Pci]).Equals(TagSet.Create([EuWest])).ShouldBeFalse(); + TagSet.Create([Pci]).Equals(TagSet.Create([Pci, EuWest])).ShouldBeFalse(); + + TagSet.Create([]).Equals(TagSet.Empty).ShouldBeTrue(); + TagSet.Create([Pci]).Equals(null).ShouldBeFalse(); + } + + [Fact] + public void ATagSet_CollapsesARepeatedTag() + { + // A repeat arrives from a payload some other client wrote. Left alone it would compare unequal to + // the same set written once, and the engine would push the host as changed on every pass for ever. + TagSet.Create([Pci, Pci]).Equals(TagSet.Create([Pci])).ShouldBeTrue(); + TagSet.Create([Pci, Pci]).Count.ShouldBe(1); + } + + [Fact] + public void ATagSet_MapsToItsOwnIdsSoAKeyedMergeIsASetMerge() + { + // The value repeats the key on purpose: no key can then hold two different values, so the only + // disagreement a keyed merge can report is one side adding what the other removed. + var map = TagSet.Create([Pci, EuWest]).ToIdMap(); + + map.Keys.Order().ShouldBe(new[] { Pci, EuWest }.Order()); + map[Pci].ShouldBe(Pci); + map[EuWest].ShouldBe(EuWest); + } + [Fact] public void TryValidate_RejectsWhatCannotBeStored() { Host(label: "").TryValidate(out _).ShouldBeFalse(); Host(hostname: " ").TryValidate(out _).ShouldBeFalse(); + Host(port: 0).TryValidate(out _).ShouldBeFalse(); Host(port: 65536).TryValidate(out _).ShouldBeFalse(); Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); + Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); + + // Null is "take the group's port", not an absent one, and it has to be storable — it is the whole + // of what inheritance stores. + Host(port: null).TryValidate(out _).ShouldBeTrue(); Host().TryValidate(out _).ShouldBeTrue(); } + [Fact] + public void AHostGivesOneAnswerAboutAuthentication_NotTwo() + { + // The same failure the key-or-credential rule catches, in the direction inheritance opened: a host + // that names a key and also says "ask me for a password" has answered one question twice, and the + // interface, the connect path and the user would each be free to pick a different answer. + var both = Host(sshKeyId: DeployKey, asksForPassword: true); + + both.TryValidate(out var reason).ShouldBeFalse(); + reason.ShouldNotBeNull().ShouldContain("not both"); + + Host(asksForPassword: true).TryValidate(out _).ShouldBeTrue(); + Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue(); + } + [Fact] public void AHostAuthenticatesOneWay_NotTwo() { diff --git a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs index c93ef13..28c86de 100644 --- a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs @@ -71,6 +71,7 @@ public sealed class AadResourceTypeTests (SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential), (SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey), (SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup), + (SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag), (SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet), (SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry), (SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry), @@ -176,6 +177,12 @@ public sealed class AadResourceTypeTests SyncEntityType.HostGroup => HostGroupCipher.Seal( NewGroup(), vaultKey, entityId, generation, version), + // Tag is the row this file was written for. It is 5 on the wire and 8 in the crypto enum, and 5 + // in the crypto enum is Credential — so a TagCipher written by casting its wire type would seal + // every tag in the vault under the resource type for a password, and only this test would say so. + SyncEntityType.Tag => TagCipher.Seal( + NewTag(), vaultKey, entityId, generation, version), + SyncEntityType.Snippet => SnippetCipher.Seal( NewSnippet(), vaultKey, entityId, generation, version), @@ -350,6 +357,8 @@ public sealed class AadResourceTypeTests private static HostGroupSecret NewGroup() => new() { Label = "production" }; + private static TagSecret NewTag() => new() { Label = "pci" }; + private static SnippetSecret NewSnippet() => new() { Label = "restart the api", diff --git a/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs b/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs index 2aa8c06..6f853e0 100644 --- a/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs @@ -24,6 +24,7 @@ public sealed class ItemKindsTests SyncEntityType.Credential, SyncEntityType.KnownHostKey, SyncEntityType.HostGroup, + SyncEntityType.Tag, SyncEntityType.Snippet, SyncEntityType.ConnectionLogEntry, SyncEntityType.ActivityLogEntry, diff --git a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs index 9f88548..3fe48b8 100644 --- a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs +++ b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs @@ -379,8 +379,13 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture()).Port.Value; + var request = new SshConnectionRequest( - host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword)); + host.Hostname, dialled, host.Username!, new SshPasswordCredential(DevStack.SshPassword)); HostKeyPresentation? pin = null;