Public Access
Merge branch 'main' into the vaults screen, and let it rotate keys too
Main built vault key rotation while this branch was reshaping the screen that would drive it, so the two met in the same three files. Every other conflict was textual and resolved by taking both; these are the ones where a decision had to be made. **The view model.** Main taught TeamsViewModel three things and this branch had renamed and rewritten it into VaultsViewModel. All three are ported rather than dropped, because each is a behaviour rather than wording: adding somebody now wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed, removing somebody rotates the vault and hands the new key to whoever is left, and a share reports how many generations were wrapped. The session calls they reach — ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list rather than to one vault, and they are called that way here rather than narrowed: adding somebody is a change to the list, so every vault the list carries is one they can now fetch. This screen makes lists that carry one vault, so the sentences name one; where a list carries several, naming them all is the honest report, and the members section already says the list is shared. AddMemberAsync ran two lines over the length limit once the sharing was in it, so the calls behind it moved to AddOrInviteAsync and the three-way refusal to WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was before the sharing arrived. **The tests.** Main's four new cases are ported to the vault-first API, including the one that matters most: the tampered key log is corrupted *before* the add, because the add is now a route to a wrap and a test that corrupted it afterwards would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey now expects two holders rather than one — main's fake records the creator's own self-grant, and a key-holder list that omitted it would show the one person who can certainly open a new vault as somebody who cannot. **The README.** The limits list is six rather than four or five: main's rotation entries and this branch's "a vault cannot be deleted" describe different things and both are true. "The rekey is flagged, never performed" is gone, since it is now performed, and M3 reads *Done* rather than *Done, except rekey*. One thing worth writing down that neither side had. An invitation claimed at sign-in still leaves the key owed, where an add does not: at the moment an invitation is issued there is no account and no published key to wrap to, and the claim happens on the invitee's machine, which holds nothing. Manual check 12.1 says so, because a reader who knows adding shares would otherwise read that step as stale. 1561 tests pass.
This commit is contained in:
@@ -904,12 +904,7 @@ internal sealed partial class VaultsViewModel(
|
||||
{
|
||||
// Never silent. This command's failures used to be visible only as a flicker of the busy
|
||||
// flag, which reads as a button that does nothing at all.
|
||||
Status = connection() is null
|
||||
? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
|
||||
: SelectedIsPersonal
|
||||
? "Your personal vault is yours alone and cannot be shared. Make a vault for the "
|
||||
+ "things you want to share, and put them in it."
|
||||
: "Select a vault on the left first — somebody is added to one vault, not to all.";
|
||||
Status = WhyNobodyCanBeAdded();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -921,62 +916,183 @@ internal sealed partial class VaultsViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
await RunAsync(() => AddOrInviteAsync(server, teamId, email, cancellationToken))
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Which of the three reasons the ADD button had nothing to act on.</summary>
|
||||
private string WhyNobodyCanBeAdded() => connection() is null
|
||||
? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
|
||||
: SelectedIsPersonal
|
||||
? "Your personal vault is yours alone and cannot be shared. Make a vault for the things you "
|
||||
+ "want to share, and put them in it."
|
||||
: "Select a vault on the left first — somebody is added to one vault, not to all.";
|
||||
|
||||
/// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary>
|
||||
private async Task AddOrInviteAsync(
|
||||
IVaultServer server,
|
||||
Guid teamId,
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var request = found.Count > 0
|
||||
? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
|
||||
: new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
|
||||
|
||||
TeamMemberSummary member;
|
||||
|
||||
try
|
||||
{
|
||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||
member = await server.Teams
|
||||
.AddTeamMemberAsync(teamId, request, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
|
||||
{
|
||||
// The address really is unknown here, which only the server can say. This is the one
|
||||
// route to an invitation, and it is now a fact rather than an inference from silence.
|
||||
await InviteAsync(server, teamId, email, cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var request = found.Count > 0
|
||||
? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
|
||||
: new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
|
||||
InviteEmail = string.Empty;
|
||||
|
||||
TeamMemberSummary member;
|
||||
// Before the reload, so the vault list this screen redraws already shows what they can open. The
|
||||
// sharing is what makes the membership worth anything, and doing it here rather than leaving a
|
||||
// SHARE KEY button to be pressed is the difference between adding a colleague and adding a
|
||||
// colleague who then waits for somebody to notice.
|
||||
var shared = await ShareWithAsync(server, teamId, member, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
try
|
||||
{
|
||||
member = await server.Teams
|
||||
.AddTeamMemberAsync(teamId, request, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
when (string.Equals(
|
||||
exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
|
||||
{
|
||||
// The address really is unknown here, which only the server can say. This is the one
|
||||
// route to an invitation, and it is now a fact rather than an inference from silence.
|
||||
await InviteAsync(server, teamId, email, cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
Status = Describe(member, shared);
|
||||
}
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
/// <summary>
|
||||
/// Wraps every vault behind this membership list that this machine can open to somebody just added.
|
||||
/// </summary>
|
||||
/// <returns>What to tell the user about the keys, or null when there was nothing to say.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The membership list rather than the one vault, and that is not a slip: adding somebody is a
|
||||
/// change to the list, so it is every vault the list carries that they can now fetch. This screen
|
||||
/// makes lists that carry one vault, so the sentence names one — and where it does not, naming them
|
||||
/// all is the honest report of what just happened.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skipped outright for an account with no identity key: there is nothing to wrap to, and a
|
||||
/// refusal per vault would bury that one fact under a list. Their row says so, and adding them was
|
||||
/// still worth doing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A failure here is reported and never thrown. The membership has already been recorded on the
|
||||
/// server and is not undone by a key that could not be wrapped — so the honest outcome is "they are
|
||||
/// in it, and this vault still needs sharing", which is a state somebody can act on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<string?> ShareWithAsync(
|
||||
IVaultServer server,
|
||||
Guid teamId,
|
||||
TeamMemberSummary member,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!member.IsEnrolled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Status = Describe(member);
|
||||
}).ConfigureAwait(true);
|
||||
if (session() is not { } open)
|
||||
{
|
||||
// Distinguished from holding no keys, because the two lead somewhere different: this one is
|
||||
// fixed by unlocking, and the other by asking somebody who holds the vault.
|
||||
return "Nothing was shared with them — a vault key is wrapped on an unlocked machine, and "
|
||||
+ "this keychain is locked.";
|
||||
}
|
||||
|
||||
var reports = await open
|
||||
.ShareTeamVaultsAsync(
|
||||
server.Grants, server.Directory, teamId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (reports.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shared = reports.Where(report => report.Succeeded).ToList();
|
||||
var refused = reports.Where(report => !report.Succeeded).ToList();
|
||||
|
||||
var sentence = shared.Count > 0
|
||||
? $"Shared {VaultCount(shared.Count)} with them: {Join(shared.Select(r => r.Name))}."
|
||||
: null;
|
||||
|
||||
if (refused.Count == 0)
|
||||
{
|
||||
return sentence;
|
||||
}
|
||||
|
||||
// Named one by one rather than counted. Each of these is a vault somebody now expects them to
|
||||
// be able to open, and which one it is decides who has to fix it.
|
||||
var reasons = refused.Select(report =>
|
||||
$"'{report.Name}' ({report.Failure?.Message ?? report.Outcome?.Message})");
|
||||
|
||||
return (sentence is null ? string.Empty : sentence + " ")
|
||||
+ $"Could not share {Join(reasons)}.";
|
||||
}
|
||||
|
||||
/// <summary>"1 vault" or "3 vaults", for a sentence that has to read either way.</summary>
|
||||
private static string VaultCount(int count) =>
|
||||
string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}");
|
||||
|
||||
/// <summary>Joins names into a phrase a person would say, rather than a comma-separated list.</summary>
|
||||
private static string Join(IEnumerable<string> parts)
|
||||
{
|
||||
var list = parts.ToList();
|
||||
|
||||
return list.Count switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => list[0],
|
||||
2 => $"{list[0]} and {list[1]}",
|
||||
_ => string.Join(", ", list.Take(list.Count - 1)) + " and " + list[^1],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What just happened to the account that was added, and what is still owed them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both branches say out loud that nothing readable was granted, because the single most common
|
||||
/// misunderstanding this design invites is that adding somebody gave them the vault. The unenrolled
|
||||
/// branch says more, and has to: their row will sit in the list saying it holds no key, and without
|
||||
/// this somebody would read that as the addition having half-failed rather than as a colleague who
|
||||
/// has not finished setting their machine up. It is also the one case where SHARE KEY cannot be the
|
||||
/// next step, so pointing at it would be pointing at a button that will refuse.
|
||||
/// <para>
|
||||
/// The enrolled branch reports what the keys did, because that is the half of "adding somebody"
|
||||
/// that this machine performs and the half that can partly fail. A vault that could not be wrapped
|
||||
/// is named there rather than left to be noticed when they say they cannot open it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The unenrolled branch says more, and has to: their row will sit in the list saying it holds no
|
||||
/// key, and without this somebody would read that as the addition having half-failed rather than as
|
||||
/// a colleague who has not finished setting their machine up. Nothing was shared with them and
|
||||
/// nothing could have been — there is no key to wrap to — so the membership is all there is yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string Describe(TeamMemberSummary member)
|
||||
private static string Describe(TeamMemberSummary member, string? shared)
|
||||
{
|
||||
var who = member.Email ?? member.DisplayName ?? "the account";
|
||||
|
||||
return member.IsEnrolled
|
||||
? $"Added {who}. They cannot read anything yet — press SHARE KEY to wrap this vault's key "
|
||||
+ "to them."
|
||||
: $"Added {who}. They have no key yet, so their row says so and this vault cannot be shared "
|
||||
+ "with them until they finish signing in on their own machine. The membership is real "
|
||||
+ "in the meantime.";
|
||||
if (!member.IsEnrolled)
|
||||
{
|
||||
return $"Added {who}. They have no key yet, so their row says so and this vault cannot be "
|
||||
+ "shared with them until they finish signing in on their own machine. The membership "
|
||||
+ "is real in the meantime.";
|
||||
}
|
||||
|
||||
return shared is null
|
||||
? $"Added {who}. This machine holds no key to give them — press SHARE KEY from one that "
|
||||
+ "does."
|
||||
: $"Added {who}. {shared}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1158,7 +1274,14 @@ internal sealed partial class VaultsViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Removes somebody, revoking every key grant they hold from this vault.</summary>
|
||||
/// <summary>
|
||||
/// Removes somebody, revoking their grants and rotating the vaults they could read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The removal and the rotation are separate acts and only the first is the server's. Nothing here
|
||||
/// undoes the removal if the rotation fails, and nothing waits for it: the membership change is what
|
||||
/// stops them fetching anything more, and it has already happened by then.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1169,21 +1292,127 @@ internal sealed partial class VaultsViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
// Read before the removal, because afterwards this list no longer contains them — and it is the
|
||||
// list of who the new key goes to.
|
||||
var remaining = Members
|
||||
.Where(row => row.UserId != member.UserId)
|
||||
.Select(row => row.UserId)
|
||||
.ToList();
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await server.Teams
|
||||
.RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var rotated = await RotateAfterRemovalAsync(server, teamId, remaining, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
|
||||
// and a message implying otherwise is the one thing this screen must not say.
|
||||
Status = $"Removed {member.Name}. They can no longer fetch this vault, and anything they had "
|
||||
+ "already downloaded is still on their machine — rotate the credentials that matter.";
|
||||
// and a message implying otherwise is the one thing this screen must not say. The rotation
|
||||
// is described in the same breath for the same reason — it decides what happens next, not
|
||||
// what already happened.
|
||||
Status = $"Removed {member.Name}. {rotated} Anything they had already downloaded is still "
|
||||
+ "on their machine — rotate the credentials that matter.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates every vault behind this membership list that this machine can open, handing each new key
|
||||
/// to the members who remain.
|
||||
/// </summary>
|
||||
/// <returns>What to tell the user about the keys. Never null — something always happened.</returns>
|
||||
/// <remarks>
|
||||
/// The list rather than the one vault, for the reason <see cref="ShareWithAsync"/> gives: removing
|
||||
/// somebody is a change to the list, so every vault it carries is one they have just lost. A vault
|
||||
/// this machine cannot open is not rotated and is not counted as a failure: its key belongs to
|
||||
/// somebody else, the server has flagged it as owing a rekey, and its row says so until one of them
|
||||
/// does it.
|
||||
/// </remarks>
|
||||
private async Task<string> RotateAfterRemovalAsync(
|
||||
IVaultServer server,
|
||||
Guid teamId,
|
||||
IReadOnlyList<Guid> remaining,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (session() is not { } open)
|
||||
{
|
||||
return "Their key grants are withdrawn, so they can fetch nothing more. Unlock your "
|
||||
+ "keychain to rotate the vault keys themselves.";
|
||||
}
|
||||
|
||||
var reports = await open
|
||||
.RekeyTeamVaultsAsync(
|
||||
server.Grants, server.Directory, server.Sync, teamId, remaining, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (reports.Count == 0)
|
||||
{
|
||||
return "Their key grants are withdrawn, so they can fetch nothing more. This machine holds "
|
||||
+ "no key to any of it, so there was nothing here to rotate.";
|
||||
}
|
||||
|
||||
var rotated = reports.Where(report => report.Rotated).ToList();
|
||||
var failed = reports.Where(report => !report.Rotated).ToList();
|
||||
|
||||
var sentences = new List<string>();
|
||||
|
||||
if (rotated.Count > 0)
|
||||
{
|
||||
sentences.AddRange(Describe(rotated));
|
||||
}
|
||||
|
||||
if (failed.Count > 0)
|
||||
{
|
||||
sentences.Add(
|
||||
$"Could not rotate {Join(failed.Select(r => $"'{r.Name}' ({r.Failure?.Message})"))}.");
|
||||
}
|
||||
|
||||
return string.Join(" ", sentences);
|
||||
}
|
||||
|
||||
/// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
|
||||
private IEnumerable<string> Describe(List<VaultRekeyReport> rotated)
|
||||
{
|
||||
yield return
|
||||
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
|
||||
+ "written from now on is readable to them.";
|
||||
|
||||
// Two different promises, so two different sentences. A vault whose items were all moved onto
|
||||
// the new key is closed to them completely; one where some were left is closed to what happens
|
||||
// next, and the difference is not the interface's to blur.
|
||||
var sealedUp = rotated.Count(report => report.Sealed);
|
||||
|
||||
yield return sealedUp == rotated.Count
|
||||
? "Everything already stored was re-sealed under the new key too, so their old key opens "
|
||||
+ "nothing."
|
||||
: $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new "
|
||||
+ "key; the rest still hold items under the old one and will be picked up next time. "
|
||||
+ "Rotate the credentials that mattered either way.";
|
||||
|
||||
// The members who did not get the new key. They are still in the vault and can still write, but
|
||||
// until somebody wraps it to them they will find it stops updating. Distinct by id rather than
|
||||
// by name, because two accounts can share a display name and collapsing them would tell
|
||||
// somebody one person is owed a key when two are.
|
||||
var missed = rotated
|
||||
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
|
||||
.Distinct()
|
||||
.Select(Name)
|
||||
.ToList();
|
||||
|
||||
if (missed.Count > 0)
|
||||
{
|
||||
yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or "
|
||||
+ "they will stop seeing changes.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
|
||||
private string Name(Guid userId) =>
|
||||
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the selected vault's key to the selected member.
|
||||
/// </summary>
|
||||
@@ -1216,8 +1445,16 @@ internal sealed partial class VaultsViewModel(
|
||||
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The generation count is said out loud when there is more than one, because it is the
|
||||
// answer to a question somebody will have about a rotated vault: whether the person they
|
||||
// just shared it with can see what was in it before the rotation.
|
||||
var history = outcome.Generations > 1
|
||||
? $" All {outcome.Generations} generations of the key were wrapped, so they can read "
|
||||
+ "what was in the vault before it was last rotated."
|
||||
: string.Empty;
|
||||
|
||||
Status = outcome.Shared
|
||||
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
|
||||
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}{history}"
|
||||
: $"Did not share '{vault.Name}': {outcome.Message}";
|
||||
|
||||
await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Reference in New Issue
Block a user