Public Access
Merge branch 'claude/vault-key-sync-sharing-d098aa'
This commit is contained in:
@@ -647,6 +647,236 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("only vault you can write to");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The gap the host's move kept running into. A key typed into a personal vault before the team existed
|
||||
/// is the key the team's machines authenticate with, and until this existed there was no way to get it
|
||||
/// across: the keychain could create and delete, so "moving" a key meant pasting the private half into a
|
||||
/// second item and deleting the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The re-aim is the half worth the test.</b> An item re-sealed into another vault lands with a new
|
||||
/// id, so without it every host bound to the key would be left naming a tombstone — and a host bound to
|
||||
/// something its vault no longer holds refuses to connect rather than falling back to a typed password.
|
||||
/// A move that did only the first half would look like a success and break two machines.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAKeyToAnotherVault_ReSealsItThereAndReAimsTheHostsThatUsedIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var key = await AddKeyAsync(vault, "deploy");
|
||||
key.VaultId.ShouldNotBe(sharedVaultId, "it was typed into the personal vault");
|
||||
|
||||
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
|
||||
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
|
||||
|
||||
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == key.EntityId);
|
||||
vault.CanMoveSelectedItem.ShouldBeTrue("there is a second vault this session can write to");
|
||||
|
||||
vault.MoveSelectedItemCommand.Execute(null);
|
||||
|
||||
vault.IsMovingItem.ShouldBeTrue(vault.Status);
|
||||
vault.ShowsItemActions.ShouldBeFalse("the panel takes the place of EDIT and DELETE");
|
||||
vault.MoveItemVaultChoices.ShouldNotContain(choice => choice.VaultId == key.VaultId);
|
||||
|
||||
// The count, before the move rather than after it. Two machines stop connecting if this is wrong.
|
||||
vault.MovingItemUsage.ShouldContain("2 hosts");
|
||||
|
||||
vault.SelectedMoveItemVault =
|
||||
vault.MoveItemVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
// As the host's move does: the pass that follows every write is made to fail, so the sentence the
|
||||
// move itself wrote is still on the status line to be read.
|
||||
server.SyncFailure = new IOException("The server is not answering.");
|
||||
|
||||
await vault.ConfirmMoveItemCommand.ExecuteAsync(null);
|
||||
|
||||
var moved = vault.Keys.ShouldHaveSingleItem();
|
||||
|
||||
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
moved.EntityId.ShouldNotBe(key.EntityId, "an id belongs to one vault");
|
||||
moved.Key.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"), "the material crossed intact");
|
||||
|
||||
vault.Hosts.Count.ShouldBe(2);
|
||||
vault.Hosts.ShouldAllBe(host => host.Host.SshKeyId == moved.EntityId);
|
||||
|
||||
vault.Status.ShouldContain("Platform secrets");
|
||||
vault.Status.ShouldContain("2 hosts");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The question this whole panel exists to ask. A binding resolves across vaults, so the moved host goes
|
||||
/// on working for the person who moved it either way — and for the colleagues it has just joined, a host
|
||||
/// whose key stayed behind is one they cannot connect with. Ticked, the key goes too and the host lands
|
||||
/// naming it by the id it landed with.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostWithItsKeyBrought_TakesTheKeyAcrossAndKeepsTheBinding()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var key = await AddKeyAsync(vault, "deploy");
|
||||
|
||||
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
|
||||
vault.MoveHostCommand.Execute(null);
|
||||
|
||||
vault.SelectedMoveVault =
|
||||
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.HasABindingToBring.ShouldBeTrue(vault.Status);
|
||||
vault.BindingToBringQuestion.ShouldContain("deploy");
|
||||
vault.BindingToBringNote.ShouldContain("Nothing else", Case.Insensitive);
|
||||
vault.BringsTheBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
|
||||
|
||||
vault.BringsTheBindingAlong = true;
|
||||
|
||||
server.SyncFailure = new IOException("The server is not answering.");
|
||||
|
||||
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var movedKey = vault.Keys.ShouldHaveSingleItem();
|
||||
var movedHost = vault.Hosts.ShouldHaveSingleItem();
|
||||
|
||||
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
|
||||
|
||||
vault.Status.ShouldContain("came with it");
|
||||
vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
|
||||
/// and the sentence afterwards says what that means for everybody else in the destination. It is also
|
||||
/// what happens to anybody who presses MOVE without reading, which is why it is the unticked state.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostWithoutItsKey_LeavesTheKeyBehindAndSaysWhatThatCosts()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var key = await AddKeyAsync(vault, "deploy");
|
||||
|
||||
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
|
||||
vault.MoveHostCommand.Execute(null);
|
||||
|
||||
vault.SelectedMoveVault =
|
||||
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
server.SyncFailure = new IOException("The server is not answering.");
|
||||
|
||||
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Keys.ShouldHaveSingleItem().VaultId.ShouldBe(key.VaultId, "the key was not asked for");
|
||||
|
||||
var moved = vault.Hosts.ShouldHaveSingleItem();
|
||||
|
||||
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
moved.Host.SshKeyId.ShouldBe(key.EntityId, "the binding is kept — it resolves across vaults");
|
||||
|
||||
vault.Status.ShouldContain("another vault");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The case where a host stops connecting without naming anything. A group lends its default key to
|
||||
/// everything filed under it, and a group belongs to the vault it is in — so the group stays behind, and
|
||||
/// a host that only inherited its key used to arrive naming nothing at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The binding is written onto the host on the way across instead, which is the same key it
|
||||
/// authenticated with before the move. The move is also asked about it: the tick box reads the resolved
|
||||
/// binding, so an inherited key can be brought too.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostThatInheritsItsGroupsKey_WritesThatBindingOntoIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var key = await AddKeyAsync(vault, "deploy");
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = "Production";
|
||||
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices
|
||||
.Single(choice => choice.EntityId == key.EntityId);
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var before = vault.Hosts.ShouldHaveSingleItem();
|
||||
|
||||
before.Host.SshKeyId.ShouldBeNull("the host names nothing; the group lends it");
|
||||
before.Authentication.ShouldBe("key");
|
||||
|
||||
vault.SelectedHost = before;
|
||||
vault.MoveHostCommand.Execute(null);
|
||||
|
||||
vault.SelectedMoveVault =
|
||||
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.HasABindingToBring.ShouldBeTrue("an inherited key is still a key that can come along");
|
||||
|
||||
server.SyncFailure = new IOException("The server is not answering.");
|
||||
|
||||
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var moved = vault.Hosts.ShouldHaveSingleItem();
|
||||
|
||||
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
moved.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
|
||||
moved.Host.SshKeyId.ShouldBe(key.EntityId, "what it inherited is written onto it");
|
||||
moved.Authentication.ShouldBe("key", "it authenticates with what it did before the move");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
|
||||
@@ -1461,6 +1691,41 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
/// Through the form rather than straight at the command, because the name is what the form is for —
|
||||
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
|
||||
/// </remarks>
|
||||
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
|
||||
private static string PrivateKey(string body) =>
|
||||
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
/// <summary>Puts one key in whatever vault the keychain is filing into, and hands back its row.</summary>
|
||||
private static async Task<SshKeyRowViewModel> AddKeyAsync(VaultViewModel vault, string label)
|
||||
{
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
|
||||
vault.KeyEditorLabel = label;
|
||||
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
|
||||
|
||||
await vault.SaveKeyCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditingKey.ShouldBeFalse(vault.Status);
|
||||
|
||||
return vault.Keys.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Creates a host that authenticates with one key, by choosing it in the editor.</summary>
|
||||
private static async Task AddHostBoundToKeyAsync(VaultViewModel vault, string label, Guid keyId)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = $"{label}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.EntityId == keyId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
}
|
||||
|
||||
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
|
||||
{
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
Reference in New Issue
Block a user