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:
@@ -484,6 +484,12 @@ internal sealed class TeamService(
|
||||
/// restore their revoked key grants: those were wrapped to a generation the vault has since been
|
||||
/// flagged to leave behind, and a member holding Share has to wrap the key afresh.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Enrollment is not required of the account being added, and asking for it would be asking the
|
||||
/// wrong question. A membership is authorization and grants nothing readable — that is the whole
|
||||
/// of ADR 0009 — so somebody can be added on Monday and publish a key on Tuesday, which is what
|
||||
/// <c>TeamMemberSummary.IsEnrolled</c> is for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<TeamMemberSummary> AddMemberAsync(
|
||||
UserAccount actor,
|
||||
@@ -500,18 +506,7 @@ internal sealed class TeamService(
|
||||
+ "by adding somebody.");
|
||||
}
|
||||
|
||||
var target = await database.Users
|
||||
.SingleOrDefaultAsync(
|
||||
u => u.Id == request.UserId && u.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
|
||||
// Safe to be specific: the caller supplied this id from a directory lookup they just
|
||||
// made, so it confirms nothing they did not already know.
|
||||
?? throw new TeamInvalidException(
|
||||
"No such account on this server. A member has to sign in here once before they can "
|
||||
+ "be added — that is what creates the account and publishes the key a vault would "
|
||||
+ "be shared with.");
|
||||
var target = await ResolveTargetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
@@ -551,6 +546,66 @@ internal sealed class TeamService(
|
||||
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the account an add is aimed at, by id when the caller has one and by address otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two are not interchangeable and the order matters. An id came from a directory lookup the
|
||||
/// caller has already made, so it names an account whose key they have seen; an address is what is
|
||||
/// left when the directory could not answer, which it cannot for anybody who has signed in but not
|
||||
/// yet published a key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only the active, undeleted account matters here, and the address is matched the way the
|
||||
/// directory matches it — the email column is citext, so the comparison is case-insensitive in the
|
||||
/// database and the partial unique index on it means at most one row can answer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both misses are specific, and neither is a new oracle. An id confirms nothing the caller did not
|
||||
/// already know from the lookup that produced it. An address is answered only for an admin or owner
|
||||
/// of the team the add names — checked by the endpoint before this runs — and is the same fact the
|
||||
/// member list would show them a moment later. It carries its own code so the caller can invite the
|
||||
/// address instead of reporting a failure at somebody who simply is not here yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<UserAccount> ResolveTargetAsync(
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.UserId != Guid.Empty)
|
||||
{
|
||||
return await database.Users
|
||||
.SingleOrDefaultAsync(
|
||||
u => u.Id == request.UserId && u.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
|
||||
?? throw new NoSuchAccountException(
|
||||
"No such account on this server. A member has to sign in here once before they "
|
||||
+ "can be added — that is what creates the account.");
|
||||
}
|
||||
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
|
||||
if (email.Length == 0)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"Say who to add: either a user id from the directory, or the email address they sign "
|
||||
+ "in with.");
|
||||
}
|
||||
|
||||
return await database.Users
|
||||
.SingleOrDefaultAsync(
|
||||
u => u.Email == email && u.DeletedAtUtc == null && u.Status == UserStatus.Active,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
|
||||
?? throw new NoSuchAccountException(
|
||||
"No account here uses that address yet. Invite it instead — they join when they "
|
||||
+ "first sign in.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enrollment is looked up rather than inferred, because it is the one field on a member row that
|
||||
/// is about them and not about the membership: somebody can be added on Monday and set their
|
||||
|
||||
Reference in New Issue
Block a user