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:
@@ -119,6 +119,25 @@ public static class ProblemCodes
|
||||
/// </remarks>
|
||||
public const string TeamNotEmpty = "team-not-empty";
|
||||
|
||||
/// <summary>
|
||||
/// An address given to <c>POST /api/v1/teams/{teamId}/members</c> has no account on this server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its own code rather than folded into <see cref="InvalidTeam"/> because it is the one add failure
|
||||
/// with a remedy the client can take unprompted: there is nobody to add, so invite the address
|
||||
/// instead. A client that could not tell this apart from a rejected role would have to either
|
||||
/// invite on every failure or never.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It answers whether an address has an account here, which <c>CreateTeamInvitationRequest</c>
|
||||
/// deliberately does not. The exposure is bounded by the same authorization the add already needs —
|
||||
/// only an admin or owner of the team reaches it — and it is what the caller learns anyway the
|
||||
/// moment the account appears in the member list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string NoSuchAccount = "no-such-account";
|
||||
|
||||
/// <summary>
|
||||
/// An invitation was rejected: a malformed address, an unknown or ownership role, an expiry the
|
||||
/// server will not issue, or an address that already has an account here.
|
||||
|
||||
@@ -13,6 +13,7 @@ const DodoSSH.Contracts.ProblemCodes.InvalidTeamInvitation = "invalid-team-invit
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.NoSuchAccount = "no-such-account" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
|
||||
@@ -23,8 +24,10 @@ const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems
|
||||
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
|
||||
DodoSSH.Contracts.AddTeamMemberRequest
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.<Clone>$() -> DodoSSH.Contracts.AddTeamMemberRequest!
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.AddTeamMemberRequest(System.Guid UserId, DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Deconstruct(out System.Guid UserId, out DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.AddTeamMemberRequest(System.Guid UserId, DodoSSH.Contracts.TeamMemberRole Role, string? Email = null) -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Deconstruct(out System.Guid UserId, out DodoSSH.Contracts.TeamMemberRole Role, out string? Email) -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Email.get -> string?
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Email.init -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Equals(DodoSSH.Contracts.AddTeamMemberRequest? other) -> bool
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Role.init -> void
|
||||
|
||||
@@ -210,14 +210,43 @@ public sealed record TeamMemberSummary(
|
||||
|
||||
/// <summary>Adds a member to a team.</summary>
|
||||
/// <remarks>
|
||||
/// By user id rather than by email, and the id comes from a directory lookup the caller has already
|
||||
/// made. That ordering is not incidental: whoever adds a member is usually about to wrap a vault key
|
||||
/// to their public key, and the key they must verify is the one the directory returned. Adding by
|
||||
/// email here would put an account resolution the client never saw between those two steps.
|
||||
/// <para>
|
||||
/// <b>By user id when the caller has one, and the id comes from a directory lookup they have already
|
||||
/// made.</b> That ordering is not incidental: whoever adds a member is usually about to wrap a vault
|
||||
/// key to their public key, and the key they must verify is the one the directory returned. Resolving
|
||||
/// an address server-side when an id was available would put an account resolution the client never
|
||||
/// saw between those two steps.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Email"/> exists because the directory cannot answer for everybody.</b> It returns
|
||||
/// only accounts that have published a key — an entry exists to be wrapped to, and one carrying no key
|
||||
/// is a check callers forget exactly once — so an account between its first sign-in and its enrollment
|
||||
/// is invisible there. It is still an account, and it can still be a member: membership is server-side
|
||||
/// authorization and grants nothing readable, which is why <see cref="TeamMemberSummary.IsEnrolled"/>
|
||||
/// exists to say that a member has no key yet. Without this field such a person could not be added at
|
||||
/// all, and a caller reading the directory's silence as "no account here" would invite an address that
|
||||
/// already has one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No key is verified on this path, and none needs to be: nothing is wrapped by adding somebody. The
|
||||
/// key that matters is fetched and checked at share time, from the directory, by the machine holding
|
||||
/// the vault key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="UserId">The account to add, as returned by the directory.</param>
|
||||
/// <param name="UserId">
|
||||
/// The account to add, as returned by the directory. <see cref="Guid.Empty"/> defers to
|
||||
/// <see cref="Email"/>.
|
||||
/// </param>
|
||||
/// <param name="Role">Role to grant.</param>
|
||||
public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
|
||||
/// <param name="Email">
|
||||
/// The address to resolve, used only when <see cref="UserId"/> is <see cref="Guid.Empty"/>. Matched
|
||||
/// case-insensitively, exactly as the directory matches. An address with no account here is refused
|
||||
/// with <see cref="ProblemCodes.NoSuchAccount"/> so the caller can offer an invitation instead.
|
||||
/// </param>
|
||||
public sealed record AddTeamMemberRequest(
|
||||
Guid UserId,
|
||||
TeamMemberRole Role,
|
||||
string? Email = null);
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
/// <param name="Role">The new role.</param>
|
||||
|
||||
Reference in New Issue
Block a user