Public Access
Let a team be joined only by somebody who is already here
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.
It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.
So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.
Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.
Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.
Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
This commit is contained in:
@@ -36,7 +36,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
grants = [];
|
||||
private readonly List<KeyLogRecord> keyLog = [];
|
||||
private readonly List<DirectoryEntry> directory = [];
|
||||
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
|
||||
|
||||
/// <summary>
|
||||
/// Every account on this fake server, enrolled or not.
|
||||
@@ -345,7 +344,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
|
||||
teams.RemoveAt(index);
|
||||
members.Remove(teamId);
|
||||
invitations.Remove(teamId);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
@@ -464,69 +462,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
return Task.FromResult(member);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
|
||||
invitations.TryGetValue(teamId, out var list) ? [.. list] : []);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
|
||||
Guid teamId,
|
||||
CreateTeamInvitationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = invitations.GetValueOrDefault(teamId, []);
|
||||
|
||||
if (list.Exists(invitation =>
|
||||
invitation.State == TeamInvitationState.Pending
|
||||
&& string.Equals(invitation.Email, request.Email, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidTeamInvitation,
|
||||
"There is already an invitation to that address for this team.");
|
||||
}
|
||||
|
||||
var invited = new TeamInvitationSummary(
|
||||
request.InvitationId,
|
||||
request.Email,
|
||||
request.Role,
|
||||
TeamInvitationState.Pending,
|
||||
UserId,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch.AddDays(14),
|
||||
AcceptedAt: null);
|
||||
|
||||
invitations[teamId] = [.. list, invited];
|
||||
|
||||
return Task.FromResult(invited);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RevokeTeamInvitationAsync(
|
||||
Guid teamId,
|
||||
Guid invitationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = invitations.GetValueOrDefault(teamId, []);
|
||||
var index = list.FindIndex(invitation =>
|
||||
invitation.InvitationId == invitationId
|
||||
&& invitation.State == TeamInvitationState.Pending);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Kept and marked rather than removed, as the server keeps it: the screen has to be able to
|
||||
// say an invitation was withdrawn rather than letting it vanish and read as never sent.
|
||||
list[index] = list[index] with { State = TeamInvitationState.Revoked };
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
@@ -697,7 +632,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
{
|
||||
teams.RemoveAll(team => team.TeamId == teamId);
|
||||
members.Remove(teamId);
|
||||
invitations.Remove(teamId);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
|
||||
@@ -30,8 +30,8 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// <para>
|
||||
/// It was <c>TeamSharingTests</c>, and the screen it drives stopped being about teams: a vault is what
|
||||
/// gets made and named, and the membership list behind it is made with it. The team is still what the
|
||||
/// server authorises against, which is why the assertions about roles, hand-over and invitations are all
|
||||
/// still here — they are the same operations, reached through the vault they apply to.
|
||||
/// server authorises against, which is why the assertions about roles and hand-over are all still here —
|
||||
/// they are the same operations, reached through the vault they apply to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultSharingTests : IAsyncLifetime
|
||||
@@ -120,7 +120,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
@@ -148,7 +148,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -190,7 +190,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
|
||||
{
|
||||
vaults.InviteEmail = address;
|
||||
vaults.NewMemberEmail = address;
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
|
||||
@@ -243,7 +243,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
|
||||
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.InviteEmail = "carol@example.com";
|
||||
vaults.NewMemberEmail = "carol@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
|
||||
@@ -275,7 +275,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
// that corrupted the log afterwards would be asserting about the second one only.
|
||||
server.CorruptKeyLog = true;
|
||||
|
||||
vaults.InviteEmail = "mallory@example.com";
|
||||
vaults.NewMemberEmail = "mallory@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
@@ -412,7 +412,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
||||
@@ -486,7 +486,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.SelectedVault = personal;
|
||||
vaults.SelectedIsShared.ShouldBeFalse();
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldBeEmpty();
|
||||
@@ -1185,7 +1185,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
// Two, and the creator is the other: their own self-grant is what makes a vault they just made
|
||||
@@ -1214,7 +1214,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -1240,7 +1240,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -1273,7 +1273,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -1324,17 +1324,19 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The address the directory does not know used to be a dead end — the screen said they had to sign
|
||||
/// in first and stopped. It invites them instead, from the same button, because which of the two
|
||||
/// applies is a fact about the server's account table rather than about what the user is doing.
|
||||
/// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>
|
||||
/// This used to issue an invitation from the same button — a standing instruction that the next
|
||||
/// account signing in with that address joined the vault. It does not any more: an address is not a
|
||||
/// way in, and only an account somebody named can be added.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The status assertion is the point of the test. Nothing is sent, and an interface that said
|
||||
/// "invited" without saying that would leave somebody waiting for an email that is never coming.
|
||||
/// The status assertion is the point of the test. "No such account" on its own is a dead end that
|
||||
/// reads as a typo, so what is pinned is that the message names the address and says the remedy —
|
||||
/// they sign in here once — and that nothing is held for them in the meantime.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
|
||||
public async Task AddingAnAddressWithNoAccount_IsRefusedAndSaysWhatHasToHappenFirst()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
@@ -1342,18 +1344,14 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "newcomer@example.com";
|
||||
vaults.NewMemberEmail = "newcomer@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||
vaults.Members.ShouldHaveSingleItem("nobody joined — there was nobody to add");
|
||||
|
||||
var invitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||
|
||||
invitation.Email.ShouldBe("newcomer@example.com");
|
||||
invitation.IsPending.ShouldBeTrue();
|
||||
invitation.State.ShouldContain("Nothing was sent");
|
||||
|
||||
vaults.Status.ShouldContain("cannot send mail");
|
||||
vaults.Status.ShouldContain("newcomer@example.com");
|
||||
vaults.Status.ShouldContain("sign in here once");
|
||||
vaults.Status.ShouldContain("Nothing is held for them");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1361,13 +1359,12 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
/// The regression this whole path was rewritten for. An account exists from its owner's first
|
||||
/// authenticated request and publishes no key until they choose a passphrase on their own machine,
|
||||
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
|
||||
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD quietly
|
||||
/// issued an invitation instead: the members list did not change, the screen said they had no
|
||||
/// account here, and they only actually joined on the next hourly sweep.
|
||||
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD refused
|
||||
/// somebody who was standing right there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
|
||||
/// what is true of them — no key, so nothing can be shared with them yet.
|
||||
/// So the assertion is that they are a member, and that the row says what is true of them — no key,
|
||||
/// so nothing can be shared with them yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -1380,11 +1377,9 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "carol@example.com";
|
||||
vaults.NewMemberEmail = "carol@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
|
||||
var member = vaults.Members.Single(row => row.UserId == colleague);
|
||||
@@ -1399,12 +1394,13 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the pair above: an address with no account at all still falls through to an
|
||||
/// invitation. It is the server that decides which, so this proves the fall-through survived being
|
||||
/// moved behind it rather than being replaced by an error.
|
||||
/// The refusal does not clear the box, and that is the half worth pinning separately. A message
|
||||
/// telling somebody to come back once that person has signed in is a message they act on later — with
|
||||
/// the address gone they would have to find it again, and the natural reading of an emptied box is
|
||||
/// that the add went through.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
|
||||
public async Task AnAddressThatWasRefused_IsStillInTheBox()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
@@ -1412,36 +1408,10 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "stranger@example.com";
|
||||
vaults.NewMemberEmail = "stranger@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||
vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
|
||||
/// disappeared would read as never having been sent, which is the same thing the screen looks like
|
||||
/// before anybody does anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "newcomer@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||
|
||||
await vaults.RevokeInvitationCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
|
||||
vaults.Status.ShouldContain("Withdrew the invitation");
|
||||
vaults.NewMemberEmail.ShouldBe("stranger@example.com");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user