Public Access
Add the member the directory cannot see, rather than inviting them
ADD MEMBER quietly issued an invitation instead of adding anybody, for everyone who had signed in here and not yet enrolled. The screen told them that address had no account, the members list did not change, and the person only actually joined on the next hourly sweep. The client decided whether an address had an account by asking the public-key directory, and the directory answers a narrower question than that. It drops every account with no current key — deliberately, because an entry exists to be wrapped to and one carrying no key is a check a caller forgets exactly once. An account exists from its owner's first authenticated request and publishes nothing until they choose a passphrase on their own machine, so every account is missing from the directory for that whole window and some indefinitely. A miss there is not an absent account, and reading it as one was the bug. The server would have taken the add. TeamService.AddMemberAsync only requires the account row, and TeamMemberSummary.IsEnrolled exists precisely so a member with no key can be listed — added on Monday, enrolled on Tuesday. The client never asked. So the directory is still asked first and the miss is retried as an add by address, and only a server saying there is no such account reaches the invitation. AddTeamMemberRequest gained an Email used when UserId is empty. The lookup-first ordering is kept because it is load-bearing for sharing and not for this: the key verified before a vault key is wrapped is the one the lookup returned, and nothing is wrapped by adding somebody. That is why resolving the address server-side is safe here and would not be there. NoSuchAccount is its own code rather than folded into InvalidTeam, because it is the one add failure the caller can act on unprompted — there is nobody to add, so invite them — and a code shared with a rejected role would leave them guessing which had happened. It does answer whether an address has an account here, which CreateTeamInvitationRequest deliberately does not. That is the property traded for the fix; the exposure is bounded by the admin check the add already needed, and it is the same fact the member list shows a moment later. Adding by a user id that does not exist now answers 404 no-such-account rather than 400 invalid-team, and nothing depended on the old pairing. The two silent returns are gone. Offline and no-team-selected set nothing and returned, so those failures were visible only as a flicker of the busy flag — which reads as a button that does nothing at all. The success line reads the enrollment flag too, because pointing an unenrolled member at SHARE KEY is pointing at a button that will refuse; their row already says it holds no key. Why nothing caught it. FakeVaultServer had one list, so it could not tell an account that does not exist from one that exists and has not enrolled — the distinction this whole path turns on — and every account it knew was enrolled by construction. It grows an accounts list beside the directory and reports IsEnrolled from whether the directory has them, rather than hardcoding true. The regression test asserts Invitations is empty, which is what fails against the old behaviour. Four tests: that pair in the shell suite, and in the API suite an unenrolled account added by address after its own directory lookup comes back empty, and an unknown address refused under the new code. 1495 tests pass.
This commit is contained in:
@@ -470,16 +470,31 @@ internal sealed partial class TeamsViewModel(
|
||||
/// Adds a member, by looking their address up in the directory first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two calls rather than one, and the order is the point: the directory is what turns an address into
|
||||
/// an account and a public key, and the key that gets verified before any sharing is the one that
|
||||
/// lookup returned. Letting the server resolve an address to an account inside the add would put an
|
||||
/// unwitnessed step between the two.
|
||||
/// <para>
|
||||
/// The directory is asked first, and the order is the point: it is what turns an address into an
|
||||
/// account <em>and a public key</em>, and the key that gets verified before any sharing is the one
|
||||
/// that lookup returned. Resolving the address server-side when the directory could answer would
|
||||
/// put an unwitnessed step between the two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A directory miss is not an absent account, and treating it as one was a bug worth naming.</b>
|
||||
/// The directory returns only accounts that have published a key, so everybody between their first
|
||||
/// sign-in and their enrollment is missing from it. Falling straight through to an invitation told
|
||||
/// somebody who was standing right there that they had no account here, left the members list
|
||||
/// unchanged, and made them wait for a sweep that runs at most hourly. So the miss is retried as an
|
||||
/// add by address, and only a server that says there is no such account reaches the invitation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AddMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
// 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 a member changes who the server will serve, so it needs a connection."
|
||||
: "Select a team on the left first — a member is added to one team, not to all of them.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -496,30 +511,59 @@ internal sealed partial class TeamsViewModel(
|
||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (found.Count == 0)
|
||||
var request = found.Count > 0
|
||||
? new AddTeamMemberRequest(found[0].UserId, NewMemberRole)
|
||||
: new AddTeamMemberRequest(Guid.Empty, NewMemberRole, email);
|
||||
|
||||
TeamMemberSummary member;
|
||||
|
||||
try
|
||||
{
|
||||
member = await server.Teams
|
||||
.AddTeamMemberAsync(team.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, team, email, cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var member = await server.Teams
|
||||
.AddTeamMemberAsync(
|
||||
team.TeamId,
|
||||
new AddTeamMemberRequest(found[0].UserId, NewMemberRole),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Said out loud, every time. The single most common misunderstanding this design invites is
|
||||
// that adding somebody gave them the vault.
|
||||
Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
|
||||
+ "cannot read anything yet — select a vault below and share its key.";
|
||||
Status = Describe(member);
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </remarks>
|
||||
private static string Describe(TeamMemberSummary member)
|
||||
{
|
||||
var who = member.Email ?? member.DisplayName ?? "the account";
|
||||
|
||||
return member.IsEnrolled
|
||||
? $"Added {who} as a member. They cannot read anything yet — select a vault below and "
|
||||
+ "share its key."
|
||||
: $"Added {who} as a member. They have no key yet, so their row says so and no vault can "
|
||||
+ "be shared with them until they finish signing in on their own machine. The "
|
||||
+ "membership is real in the meantime.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invites an address the directory does not know.
|
||||
/// </summary>
|
||||
@@ -531,6 +575,11 @@ internal sealed partial class TeamsViewModel(
|
||||
/// is reported afterwards, because the difference decides what they have to do next.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is reached only after the server has said there is no such account. The directory's silence
|
||||
/// is not enough and never was: it omits everybody who has not published a key, so inviting on the
|
||||
/// strength of it told people with accounts that they had none.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
|
||||
/// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
|
||||
/// better.
|
||||
|
||||
Reference in New Issue
Block a user