Public Access
Merge branch 'claude/vault-key-sync-sharing-d098aa'
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -2053,6 +2054,41 @@ internal sealed partial class VaultViewModel(
|
||||
SelectedHost is { IsReadOnly: false } row
|
||||
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the open move panel has a key or password it could bring with the host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Read against the vault in the picker rather than against the host, so choosing a different
|
||||
/// destination re-asks the question: a key already sitting in the vault the host is going to has nothing
|
||||
/// to move, and offering to move it there would be offering to do nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The binding is the <em>resolved</em> one, so a key the host only inherits from its group counts. That
|
||||
/// is the case this question matters most in — the group stays behind, so a host that inherited its key
|
||||
/// arrives naming nothing at all unless the move writes the binding onto it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool HasABindingToBring => BindingOfTheMovingHost() is not null;
|
||||
|
||||
/// <summary>What the tick box beside the move picker says.</summary>
|
||||
internal string BindingToBringQuestion => BindingOfTheMovingHost() is { } binding
|
||||
? $"Bring the {binding.Noun} '{binding.Label}' too"
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// What bringing it would do to everything else that uses it, and what leaving it would do to the host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both halves, because both are decisions. The hosts that also authenticate with it are re-aimed at the
|
||||
/// key's new vault and go on working for whoever can read both — but for the members of the vault it
|
||||
/// left, it is gone; and a host that arrives without its key is a host its new colleagues cannot connect
|
||||
/// with. Neither is the wrong answer, which is why this is a question rather than a rule.
|
||||
/// </remarks>
|
||||
internal string BindingToBringNote => BindingOfTheMovingHost() is { } binding
|
||||
? WhatElseUses(binding.Kind, binding.EntityId, binding.Label, besidesHost: movingHostId)
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel asking which vault to move the group to is up.
|
||||
/// </summary>
|
||||
@@ -2098,6 +2134,86 @@ internal sealed partial class VaultViewModel(
|
||||
// opened and its entries do not move. The one place the question decides anything is MoveGroup, which
|
||||
// asks it by building the picker and saying so when it comes back empty.
|
||||
|
||||
/// <summary>
|
||||
/// Whether the host's move panel is offering to bring the key or password it authenticates with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Off unless it is ticked</b>, and that is not a default chosen for tidiness. Moving a key into a
|
||||
/// team's vault hands it to everybody who holds that vault's key — it is a disclosure, and the same rule
|
||||
/// <see cref="TargetVaultId"/> follows applies: filing something where other people can read it is
|
||||
/// chosen, never defaulted into. Leaving it off is also the state that was there before this question
|
||||
/// existed, so somebody pressing MOVE without reading gets what they used to get.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The alternative — moving the host and quietly copying the key — was rejected for the reason the
|
||||
/// keychain has one item per key: two items holding the same private half cannot be told apart
|
||||
/// afterwards, and rotating the key means finding both.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool bringsTheBindingAlong;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel asking which vault a keychain item should move to is up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The host's panel — see <see cref="IsMovingHost"/> — over on the keychain, where until now a key was
|
||||
/// stuck in the vault it was typed into for ever. It takes the place of that pane's EDIT and DELETE
|
||||
/// while it is open, as the deletion question does, so the pane asks one thing at a time.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsItemActions))]
|
||||
private bool isMovingItem;
|
||||
|
||||
/// <summary>Which keychain item the open move panel is about. Null when it is closed.</summary>
|
||||
/// <inheritdoc cref="movingHostId" path="/remarks" />
|
||||
private Guid? movingItemId;
|
||||
|
||||
/// <summary>Which kind of item that id belongs to, so the confirmation knows which repository to ask.</summary>
|
||||
private VaultItemKind movingItemKind;
|
||||
|
||||
/// <summary>Where that item lives now. Held for the same reason its id is.</summary>
|
||||
private Guid movingItemVaultId;
|
||||
|
||||
/// <summary>Where the selected keychain item could go: every vault this session can write to but its own.</summary>
|
||||
internal ObservableCollection<VaultChoiceViewModel> MoveItemVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedMoveItemVault;
|
||||
|
||||
/// <summary>The item the open move panel is about, by name.</summary>
|
||||
/// <inheritdoc cref="MovingGroupLabel" path="/remarks" />
|
||||
[ObservableProperty]
|
||||
private string movingItemLabel = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// What else points at the item about to move, said before the move rather than after it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The count is the whole of what makes this decidable. A key is the one item in this vault that other
|
||||
/// items name, so moving one is never only about the key: every host bound to it and every group lending
|
||||
/// it is re-aimed at the new id, and somebody about to move a key twenty machines authenticate with
|
||||
/// should see the twenty before they press it, not read about them in the sentence afterwards.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMovingItemUsage))]
|
||||
private string movingItemUsage = string.Empty;
|
||||
|
||||
/// <summary>Whether anything at all points at the item the move panel is about.</summary>
|
||||
internal bool HasMovingItemUsage => MovingItemUsage.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected keychain item can be moved to another vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keys and passwords only. A tag, a bucket and a pin are read from the active vault alone, so "another
|
||||
/// vault" is not a question any of them has — and a key is the item this exists for: it is the one thing
|
||||
/// on this screen that other vaults' hosts genuinely authenticate with.
|
||||
/// </remarks>
|
||||
internal bool CanMoveSelectedItem =>
|
||||
MovableRow() is { IsReadOnly: false } item && CanLeaveItsVault(item.VaultId);
|
||||
|
||||
/// <summary>
|
||||
/// What the drawer's header says it is about.
|
||||
/// </summary>
|
||||
@@ -2829,7 +2945,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
||||
/// <inheritdoc cref="ShowsHostActions" />
|
||||
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
|
||||
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion && !IsMovingItem;
|
||||
|
||||
// ---- Connecting ----
|
||||
|
||||
@@ -6171,19 +6287,35 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
var name = target.Name;
|
||||
var dropped = WhatWasLeftBehind(row.Host);
|
||||
var stranded = BindingOutside(row.Host, target.VaultId);
|
||||
|
||||
var moved = row.Host with { GroupId = null, TagIds = TagSet.Empty };
|
||||
// Read before the panel is folded away, because all three of these are answered against it.
|
||||
var bringing = BringsTheBindingAlong ? BindingOfTheMovingHost() : null;
|
||||
var stranded = bringing is null ? BindingOutside(row, target.VaultId) : string.Empty;
|
||||
|
||||
var moved = Detached(row.Host, row.Resolved.Binding);
|
||||
|
||||
IsMovingHost = false;
|
||||
movingHostId = null;
|
||||
MoveVaultChoices.Clear();
|
||||
SelectedMoveVault = null;
|
||||
BringsTheBindingAlong = false;
|
||||
|
||||
await RunAsync(
|
||||
"Moving…",
|
||||
async () =>
|
||||
{
|
||||
var carried = string.Empty;
|
||||
|
||||
// The binding first, so the host can be written naming the id it landed with. An
|
||||
// interruption between the two leaves the key in the destination and the host still in the
|
||||
// vault it started in, pointing at a tombstone — visible, and repaired by moving it again.
|
||||
if (bringing is { } bring)
|
||||
{
|
||||
(moved, carried) = await CarriedAlongAsync(
|
||||
bring, moved, row.EntityId, target.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
var entityId = await session.Hosts
|
||||
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
@@ -6192,7 +6324,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
|
||||
|
||||
Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}";
|
||||
Status = $"Moved '{row.Label}' to {name}.{dropped}{carried}{stranded}";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
|
||||
@@ -6210,25 +6342,134 @@ internal sealed partial class VaultViewModel(
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The host as it will be written on the other side: no group, no tags, and its binding spelled out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The group and the tags go for the reason <see cref="ConfirmMoveHostAsync"/> gives. <b>The binding is
|
||||
/// written onto the host when it came from a group</b>, and that is the half this used to lose: the
|
||||
/// group stays behind, so a host that inherited its key arrived in the destination naming nothing at all
|
||||
/// and authenticating with nothing — a machine that connected before the move and refused after it, with
|
||||
/// no sentence anywhere saying why.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only the inherited case writes anything. A host that names its own key already carries it, and one
|
||||
/// that types its password says so with <c>AsksForPassword</c>, which is an answer rather than a gap.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static HostSecret Detached(HostSecret host, ResolvedBinding binding)
|
||||
{
|
||||
var moved = host with { GroupId = null, TagIds = TagSet.Empty };
|
||||
|
||||
if (!binding.IsInherited || binding.EntityId is not { } entityId)
|
||||
{
|
||||
return moved;
|
||||
}
|
||||
|
||||
return binding.Kind is ResolvedBindingKind.SshKey
|
||||
? moved with { SshKeyId = entityId }
|
||||
: moved with { CredentialId = entityId };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the host's key or password across with it, and re-aims everything else that named it.
|
||||
/// </summary>
|
||||
/// <returns>The host as it should now be written, and what to say about what came with it.</returns>
|
||||
/// <remarks>
|
||||
/// The moving host is left out of the re-aim and given the new id directly, because it is about to be
|
||||
/// written into another vault anyway: re-aiming it would be a save in the vault it is leaving, followed
|
||||
/// immediately by a tombstone for the row that save had just amended.
|
||||
/// </remarks>
|
||||
private async Task<(HostSecret Host, string Note)> CarriedAlongAsync(
|
||||
MovableBinding bring,
|
||||
HostSecret moved,
|
||||
Guid movingHostId,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (hosts, groups) = PointingAt(bring.Kind, bring.EntityId);
|
||||
|
||||
if (await MoveTheBindingAsync(bring, vaultId, cancellationToken).ConfigureAwait(true)
|
||||
is not { } landed)
|
||||
{
|
||||
return (moved, string.Empty);
|
||||
}
|
||||
|
||||
var reaimed = await ReAimAtAsync(
|
||||
bring.Kind,
|
||||
landed,
|
||||
hosts.Where(host => host.EntityId != movingHostId),
|
||||
groups,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
return (
|
||||
bring.Kind is ResolvedBindingKind.SshKey
|
||||
? moved with { SshKeyId = landed }
|
||||
: moved with { CredentialId = landed },
|
||||
$" The {bring.Noun} '{bring.Label}' came with it.{WhatFollowedIt(reaimed)}");
|
||||
}
|
||||
|
||||
/// <summary>Re-seals one key or password into another vault, or null when its row has gone.</summary>
|
||||
/// <remarks>
|
||||
/// Null rather than a throw, because the row is read from a list a background sync can replace: the
|
||||
/// honest outcome is a host that moves and keeps naming the key where it was, which is exactly what
|
||||
/// leaving the tick box alone would have done.
|
||||
/// </remarks>
|
||||
private async Task<Guid?> MoveTheBindingAsync(
|
||||
MovableBinding binding,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (binding.Kind is ResolvedBindingKind.SshKey)
|
||||
{
|
||||
return Keys.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } key
|
||||
? null
|
||||
: await session.SshKeys
|
||||
.MoveAsync(binding.VaultId, vaultId, binding.EntityId, key.Key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return Credentials.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } credential
|
||||
? null
|
||||
: await session.Credentials
|
||||
.MoveAsync(
|
||||
binding.VaultId, vaultId, binding.EntityId, credential.Credential, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The warning about a key or password that is not in the vault the host has moved to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Named rather than counted, because which one it is decides what to do about it — and the answer is
|
||||
/// usually to put a copy of that key in the destination vault, which needs to know which key.
|
||||
/// <para>
|
||||
/// Named rather than counted, because which one it is decides what to do about it — and the answer is to
|
||||
/// bring that key across, which is the tick box beside the picker and needs to know which key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read from the resolved binding, so a key the host only inherits is warned about too. It is written
|
||||
/// onto the host by <see cref="Detached"/> on the way over, so it is genuinely what the moved host
|
||||
/// authenticates with — and it is the case where somebody is least likely to know a key is involved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private string BindingOutside(HostSecret host, Guid vaultId)
|
||||
private string BindingOutside(HostRowViewModel row, Guid vaultId)
|
||||
{
|
||||
if (host.SshKeyId is { } keyId
|
||||
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
|
||||
&& key.VaultId != vaultId)
|
||||
if (row.Resolved.Binding is not { EntityId: { } entityId } binding)
|
||||
{
|
||||
return $" It still authenticates with the key '{key.Label}', which is in another vault — "
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (binding.Kind is ResolvedBindingKind.SshKey
|
||||
&& Keys.FirstOrDefault(key => key.EntityId == entityId) is { } stored
|
||||
&& stored.VaultId != vaultId)
|
||||
{
|
||||
return $" It still authenticates with the key '{stored.Label}', which is in another vault — "
|
||||
+ "everybody else in this one will find that binding unresolvable.";
|
||||
}
|
||||
|
||||
if (host.CredentialId is { } credentialId
|
||||
&& Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential
|
||||
if (binding.Kind is ResolvedBindingKind.Credential
|
||||
&& Credentials.FirstOrDefault(stored => stored.EntityId == entityId) is { } credential
|
||||
&& credential.VaultId != vaultId)
|
||||
{
|
||||
return $" It still authenticates with the password '{credential.Label}', which is in another "
|
||||
@@ -6617,6 +6858,452 @@ internal sealed partial class VaultViewModel(
|
||||
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>A keychain item that could be moved, with what the panel needs to say about it.</summary>
|
||||
/// <param name="Kind">Which list it came from, so the confirmation knows which repository to ask.</param>
|
||||
/// <param name="EntityId">The item.</param>
|
||||
/// <param name="Label">What it is called.</param>
|
||||
/// <param name="VaultId">The vault it is in now.</param>
|
||||
/// <param name="IsReadOnly">Whether this build can re-encode it. A move re-encodes.</param>
|
||||
private sealed record MovableItem(
|
||||
VaultItemKind Kind,
|
||||
Guid EntityId,
|
||||
string Label,
|
||||
Guid VaultId,
|
||||
bool IsReadOnly);
|
||||
|
||||
/// <summary>A key or password a host's move could carry, resolved to the row that holds it.</summary>
|
||||
/// <param name="Kind">Key or password.</param>
|
||||
/// <param name="EntityId">The item.</param>
|
||||
/// <param name="Label">What it is called.</param>
|
||||
/// <param name="VaultId">The vault it is in now, which is not the one the host is going to.</param>
|
||||
private sealed record MovableBinding(
|
||||
ResolvedBindingKind Kind,
|
||||
Guid EntityId,
|
||||
string Label,
|
||||
Guid VaultId)
|
||||
{
|
||||
/// <summary>What to call it in a sentence a person reads.</summary>
|
||||
internal string Noun => Kind is ResolvedBindingKind.SshKey ? "key" : "password";
|
||||
}
|
||||
|
||||
/// <summary>How many things a move re-aimed, and how many it could not.</summary>
|
||||
/// <param name="Hosts">Hosts whose own binding now names the item's new id.</param>
|
||||
/// <param name="Groups">Groups whose default now names it.</param>
|
||||
/// <param name="Refused">
|
||||
/// Things left naming the old id, because this build cannot re-encode them or this account cannot
|
||||
/// write to the vault they are in. Counted rather than swallowed: each one is a host that will refuse
|
||||
/// to connect, and the sentence afterwards says how many.
|
||||
/// </param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private readonly record struct ReAimed(int Hosts, int Groups, int Refused);
|
||||
|
||||
/// <summary>The selected keychain row, when it is one of the kinds a vault can hand to another.</summary>
|
||||
/// <inheritdoc cref="CanMoveSelectedItem" path="/remarks" />
|
||||
private MovableItem? MovableRow() => SelectedVaultItem?.Kind switch
|
||||
{
|
||||
VaultItemKind.Key when SelectedKey is { } key =>
|
||||
new MovableItem(VaultItemKind.Key, key.EntityId, key.Label, key.VaultId, key.IsReadOnly),
|
||||
|
||||
VaultItemKind.Credential when SelectedCredential is { } credential => new MovableItem(
|
||||
VaultItemKind.Credential,
|
||||
credential.EntityId,
|
||||
credential.Label,
|
||||
credential.VaultId,
|
||||
credential.IsReadOnly),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Whether there is a vault to move something out of this one into.</summary>
|
||||
private bool CanLeaveItsVault(Guid vaultId) =>
|
||||
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != vaultId);
|
||||
|
||||
/// <summary>Whether this account may write to one vault at all.</summary>
|
||||
/// <remarks>
|
||||
/// Asked before every re-aim. A viewer of a team vault can read the hosts in it and cannot save one, so
|
||||
/// a key move that tried would queue an operation the server refuses — and the honest answer is to leave
|
||||
/// that host naming the old id and say so, rather than to fail the move that had already happened.
|
||||
/// </remarks>
|
||||
private bool CanWriteTo(Guid vaultId) =>
|
||||
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId == vaultId);
|
||||
|
||||
/// <summary>The binding kind that goes with a keychain row's kind.</summary>
|
||||
private static ResolvedBindingKind BindingKindOf(VaultItemKind kind) =>
|
||||
kind is VaultItemKind.Key ? ResolvedBindingKind.SshKey : ResolvedBindingKind.Credential;
|
||||
|
||||
/// <summary>
|
||||
/// Everything that names one key or password by id: the hosts that bind it and the groups that lend it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hosts' <em>own</em> ids rather than their resolved bindings, which is the opposite of what
|
||||
/// <see cref="HostsBoundTo"/> reads and is right for the opposite reason. That one warns a person, so it
|
||||
/// counts everybody who would stop connecting, inherited or not. This one drives writes: a host that
|
||||
/// inherits its key names nothing, so rewriting it would put a binding on a host that never had one —
|
||||
/// the group it inherits from is in this list and is the one thing that has to change.
|
||||
/// </remarks>
|
||||
private (List<HostRowViewModel> Hosts, List<HostGroupRowViewModel> Groups) PointingAt(
|
||||
ResolvedBindingKind kind,
|
||||
Guid entityId)
|
||||
{
|
||||
var hosts = Hosts
|
||||
.Where(row => OwnBinding(row.Host, kind) == entityId)
|
||||
.ToList();
|
||||
|
||||
var groups = Groups
|
||||
.Where(row => DefaultBinding(row.Group, kind) == entityId)
|
||||
.ToList();
|
||||
|
||||
return (hosts, groups);
|
||||
}
|
||||
|
||||
private static Guid? OwnBinding(HostSecret host, ResolvedBindingKind kind) =>
|
||||
kind is ResolvedBindingKind.SshKey ? host.SshKeyId : host.CredentialId;
|
||||
|
||||
private static Guid? DefaultBinding(HostGroupSecret group, ResolvedBindingKind kind) =>
|
||||
kind is ResolvedBindingKind.SshKey ? group.DefaultSshKeyId : group.DefaultCredentialId;
|
||||
|
||||
/// <summary>
|
||||
/// Points everything that named a moved key or password at the id it landed with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Without this a move is a deletion with extra steps.</b> An item re-sealed into another vault takes
|
||||
/// a new id — see <c>VaultItemRepository.MoveAsync</c> — so every host bound to the old one would be
|
||||
/// left naming a tombstone and would refuse to connect rather than fall back to a typed password. The
|
||||
/// bindings themselves cross vaults perfectly well; it is only the id that changes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host this build cannot re-encode, or one in a vault this account cannot write to, is skipped and
|
||||
/// counted. Failing the whole move instead would be worse: the item has already landed, and the
|
||||
/// alternative to a partial re-aim is none at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<ReAimed> ReAimAtAsync(
|
||||
ResolvedBindingKind kind,
|
||||
Guid landedId,
|
||||
IEnumerable<HostRowViewModel> hosts,
|
||||
IEnumerable<HostGroupRowViewModel> groups,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rebound = 0;
|
||||
var relent = 0;
|
||||
var refused = 0;
|
||||
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
if (host.IsReadOnly || !CanWriteTo(host.VaultId))
|
||||
{
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await session.Hosts
|
||||
.UpdateAsync(
|
||||
host.VaultId,
|
||||
host.EntityId,
|
||||
kind is ResolvedBindingKind.SshKey
|
||||
? host.Host with { SshKeyId = landedId }
|
||||
: host.Host with { CredentialId = landedId },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
rebound++;
|
||||
}
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.IsReadOnly || !CanWriteTo(group.VaultId))
|
||||
{
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await session.HostGroups
|
||||
.UpdateAsync(
|
||||
group.VaultId,
|
||||
group.EntityId,
|
||||
kind is ResolvedBindingKind.SshKey
|
||||
? group.Group with { DefaultSshKeyId = landedId }
|
||||
: group.Group with { DefaultCredentialId = landedId },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
relent++;
|
||||
}
|
||||
|
||||
return new ReAimed(rebound, relent, refused);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key or password the open host move panel could carry, or null when there is nothing to carry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null in four cases, and each is a case where the tick box would be a lie: the host authenticates with
|
||||
/// a typed password, the binding dangles already, the item is in the vault the host is going to, or it is
|
||||
/// one this build cannot re-encode.
|
||||
/// </remarks>
|
||||
private MovableBinding? BindingOfTheMovingHost()
|
||||
{
|
||||
if (!IsMovingHost
|
||||
|| movingHostId is not { } hostId
|
||||
|| Hosts.FirstOrDefault(row => row.EntityId == hostId) is not { } host
|
||||
|| SelectedMoveVault is not { } target
|
||||
|| host.Resolved.Binding is not { EntityId: { } entityId } binding)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return binding.Kind switch
|
||||
{
|
||||
ResolvedBindingKind.SshKey =>
|
||||
Keys.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } key
|
||||
&& key.VaultId != target.VaultId
|
||||
&& CanWriteTo(key.VaultId)
|
||||
? new MovableBinding(binding.Kind, entityId, key.Label, key.VaultId)
|
||||
: null,
|
||||
|
||||
ResolvedBindingKind.Credential =>
|
||||
Credentials.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } stored
|
||||
&& stored.VaultId != target.VaultId
|
||||
&& CanWriteTo(stored.VaultId)
|
||||
? new MovableBinding(binding.Kind, entityId, stored.Label, stored.VaultId)
|
||||
: null,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Re-asks the binding question, which is answered against the vault in the picker.</summary>
|
||||
private void TheBindingQuestionChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasABindingToBring));
|
||||
OnPropertyChanged(nameof(BindingToBringQuestion));
|
||||
OnPropertyChanged(nameof(BindingToBringNote));
|
||||
}
|
||||
|
||||
partial void OnSelectedMoveVaultChanged(VaultChoiceViewModel? value) => TheBindingQuestionChanged();
|
||||
|
||||
partial void OnIsMovingHostChanged(bool value) => TheBindingQuestionChanged();
|
||||
|
||||
/// <summary>What else authenticates with one item, for the tick box beside the host's picker.</summary>
|
||||
private string WhatElseUses(ResolvedBindingKind kind, Guid entityId, string label, Guid? besidesHost)
|
||||
{
|
||||
var (hosts, groups) = PointingAt(kind, entityId);
|
||||
var others = hosts.Count(row => row.EntityId != besidesHost);
|
||||
|
||||
return Users(others, groups.Count, "other host") is not { Length: > 0 } phrase
|
||||
? $"Nothing else authenticates with '{label}', so nothing is left behind by bringing it."
|
||||
: $"Also used by {phrase}, which will be re-aimed at it in its new vault — and for anybody else "
|
||||
+ "in the vault it leaves, it is gone.";
|
||||
}
|
||||
|
||||
/// <summary>What uses one item, for the keychain's own move panel.</summary>
|
||||
private string WhatUses(ResolvedBindingKind kind, Guid entityId)
|
||||
{
|
||||
var (hosts, groups) = PointingAt(kind, entityId);
|
||||
|
||||
return Users(hosts.Count, groups.Count, "host") is not { Length: > 0 } phrase
|
||||
? string.Empty
|
||||
: $"Used by {phrase}, which will be re-aimed at it in the vault it moves to.";
|
||||
}
|
||||
|
||||
/// <summary>The hosts and groups that name something, counted into a phrase.</summary>
|
||||
/// <remarks>
|
||||
/// Empty when nothing does, so each caller can say its own sentence about nothing rather than being
|
||||
/// handed "0 hosts" to put in the middle of one.
|
||||
/// </remarks>
|
||||
private static string Users(int hosts, int groups, string hostNoun)
|
||||
{
|
||||
var machines = hosts switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => $"one {hostNoun}",
|
||||
_ => $"{hosts} {hostNoun}s",
|
||||
};
|
||||
|
||||
var shelves = groups switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => "one group",
|
||||
_ => $"{groups} groups",
|
||||
};
|
||||
|
||||
return (machines, shelves) switch
|
||||
{
|
||||
("", "") => string.Empty,
|
||||
("", _) => shelves,
|
||||
(_, "") => machines,
|
||||
_ => $"{machines} and {shelves}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the panel that asks which vault the selected key or password should move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The host's panel again — see <see cref="MoveHost"/> — and the gap it closes is the one the host's
|
||||
/// move kept running into: moving a machine into a team's vault left the key it authenticates with in
|
||||
/// the vault it came from, where the team cannot read it. Until now the only remedy was to paste the
|
||||
/// private half into a second item, which is a private key on a clipboard and two items nobody can tell
|
||||
/// apart afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for an item written by a newer client, exactly as editing one is: the move re-encodes the
|
||||
/// payload, so a field this build cannot represent would be dropped on the way across.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void MoveSelectedItem()
|
||||
{
|
||||
if (MovableRow() is not { } item || AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsReadOnly)
|
||||
{
|
||||
Status = "This was written by a newer version of DodoSSH. Moving it would re-encode it here and "
|
||||
+ "lose what this build cannot read. Update first.";
|
||||
return;
|
||||
}
|
||||
|
||||
BuildMoveItemVaultChoices(item.VaultId);
|
||||
|
||||
if (MoveItemVaultChoices.Count == 0)
|
||||
{
|
||||
Status = $"There is nowhere to move '{item.Label}' to: this is the only vault you can write to.";
|
||||
return;
|
||||
}
|
||||
|
||||
// As the host's panel disarms a deletion aimed at the same host: two questions about one item, one
|
||||
// of which destroys it, is not a pane anybody should have to read carefully.
|
||||
PendingDeletion = null;
|
||||
movingItemId = item.EntityId;
|
||||
movingItemKind = item.Kind;
|
||||
movingItemVaultId = item.VaultId;
|
||||
MovingItemLabel = item.Label;
|
||||
MovingItemUsage = WhatUses(BindingKindOf(item.Kind), item.EntityId);
|
||||
IsMovingItem = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the keychain's move panel.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelMoveItem()
|
||||
{
|
||||
if (!IsMovingItem)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsMovingItem = false;
|
||||
movingItemId = null;
|
||||
MovingItemLabel = string.Empty;
|
||||
MovingItemUsage = string.Empty;
|
||||
MoveItemVaultChoices.Clear();
|
||||
SelectedMoveItemVault = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the key or password into the chosen vault, and re-aims everything that named it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The item first, the re-aims after</b>, because each of those has to name the id it landed with.
|
||||
/// What an interruption between them leaves is a key in its new vault and some hosts still naming the
|
||||
/// old one, which is visible — those hosts say they cannot resolve their binding — and repaired by
|
||||
/// binding them again. The other order cannot be written at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The hosts are re-aimed across every vault they are in, not only the one the key came from.</b> A
|
||||
/// binding resolves over everything this session can read, which is the arrangement one key on twenty
|
||||
/// hosts in three vaults exists for — so a re-aim scoped to one vault would quietly break the other two.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmMoveItemAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (movingItemId is not { } entityId
|
||||
|| SelectedMoveItemVault is not { } target
|
||||
|| MovableRow() is not { IsReadOnly: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var kind = movingItemKind;
|
||||
var from = movingItemVaultId;
|
||||
var label = MovingItemLabel;
|
||||
var bindingKind = BindingKindOf(kind);
|
||||
var (hosts, groups) = PointingAt(bindingKind, entityId);
|
||||
var name = target.Name;
|
||||
|
||||
var key = Keys.FirstOrDefault(row => row.EntityId == entityId);
|
||||
var credential = Credentials.FirstOrDefault(row => row.EntityId == entityId);
|
||||
|
||||
CancelMoveItemCommand.Execute(null);
|
||||
|
||||
await RunAsync(
|
||||
"Moving…",
|
||||
async () =>
|
||||
{
|
||||
var landed = kind is VaultItemKind.Key
|
||||
? await session.SshKeys
|
||||
.MoveAsync(from, target.VaultId, entityId, key!.Key, cancellationToken)
|
||||
.ConfigureAwait(true)
|
||||
: await session.Credentials
|
||||
.MoveAsync(from, target.VaultId, entityId, credential!.Credential, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var reaimed = await ReAimAtAsync(
|
||||
bindingKind, landed, hosts, groups, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// By its new id, as a moved host's pane is: leaving the pane on the row it came from would
|
||||
// read as the item having been deleted rather than moved.
|
||||
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == landed);
|
||||
|
||||
Status = $"Moved '{label}' to {name}.{WhatFollowedIt(reaimed)}";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>What the re-aim achieved, said as the counts somebody can check against the cards.</summary>
|
||||
private static string WhatFollowedIt(ReAimed reaimed)
|
||||
{
|
||||
var followed = Users(reaimed.Hosts, reaimed.Groups, "host") is { Length: > 0 } phrase
|
||||
? $" {phrase} now point at it there."
|
||||
: string.Empty;
|
||||
|
||||
var left = reaimed.Refused switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => " One thing that used it could not be rewritten here and still names the old item; it will "
|
||||
+ "refuse to connect until it is bound again.",
|
||||
_ => $" {reaimed.Refused} things that used it could not be rewritten here and still name the old "
|
||||
+ "item; they will refuse to connect until they are bound again.",
|
||||
};
|
||||
|
||||
return followed + left;
|
||||
}
|
||||
|
||||
/// <summary>Fills the keychain move panel's picker with every vault this session can write to but that one.</summary>
|
||||
private void BuildMoveItemVaultChoices(Guid vaultId)
|
||||
{
|
||||
MoveItemVaultChoices.Clear();
|
||||
|
||||
foreach (var choice in WritableVaultsBesides(vaultId))
|
||||
{
|
||||
MoveItemVaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
SelectedMoveItemVault = MoveItemVaultChoices.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Asks whether the selected host should go.</summary>
|
||||
/// <remarks>
|
||||
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
||||
@@ -9392,6 +10079,32 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds the bucket rows to the table, when the table is showing them.</summary>
|
||||
/// <remarks>
|
||||
/// Out of <see cref="RebuildVaultItems"/> for the reason <see cref="AddTagRows"/> is — length — and this
|
||||
/// is the arm that left rather than the newest one, because a rebuild that also has to say whether the
|
||||
/// selected row can be moved has one line more than it can hold.
|
||||
/// </remarks>
|
||||
private void AddBucketRows()
|
||||
{
|
||||
if (Section is not (VaultSection.All or VaultSection.Buckets))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var store in ObjectStores)
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.ObjectStore,
|
||||
store.EntityId,
|
||||
store.Label,
|
||||
"BUCKET",
|
||||
store.Description,
|
||||
store.Badge,
|
||||
store.HasUnsyncedChanges));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the vault table from the typed lists.
|
||||
/// </summary>
|
||||
@@ -9443,21 +10156,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
AddTagRows();
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Buckets)
|
||||
{
|
||||
foreach (var store in ObjectStores)
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.ObjectStore,
|
||||
store.EntityId,
|
||||
store.Label,
|
||||
"BUCKET",
|
||||
store.Description,
|
||||
store.Badge,
|
||||
store.HasUnsyncedChanges));
|
||||
}
|
||||
}
|
||||
AddBucketRows();
|
||||
|
||||
// The selection survives a reload, as every other list's does, and for the same reason: a background
|
||||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||||
@@ -9467,6 +10166,11 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(HasVaultItems));
|
||||
OnPropertyChanged(nameof(TotalItemCount));
|
||||
OnPropertyChanged(nameof(EmptySectionMessage));
|
||||
|
||||
// A reload replaces every row object, and the selection is restored by id — so the setter above may
|
||||
// not have fired even though the row this answers about is a different instance. Asked again here,
|
||||
// because the answer decides whether the pane draws MOVE at all.
|
||||
OnPropertyChanged(nameof(CanMoveSelectedItem));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -9508,6 +10212,18 @@ internal sealed partial class VaultViewModel(
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// After the switch, not with the three above it: this one is answered from the typed selection the
|
||||
// switch has just made, so asking before it would answer about the row that was selected before.
|
||||
OnPropertyChanged(nameof(CanMoveSelectedItem));
|
||||
|
||||
// The move panel names one item and its picker is built from that item's vault, so a selection that
|
||||
// has gone elsewhere has left it aimed at something nobody is looking at. The deletion question
|
||||
// above is disarmed the same way and for the same reason.
|
||||
if (IsMovingItem && movingItemId != value?.EntityId)
|
||||
{
|
||||
CancelMoveItemCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user