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:
@@ -5,7 +5,7 @@ using DodoSSH.Contracts;
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, membership, invitations and the vault key grants that make a team vault readable.
|
||||
/// Teams, membership and the vault key grants that make a team vault readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -24,9 +24,9 @@ namespace DodoSSH.Api.Tests;
|
||||
/// <para>
|
||||
/// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible
|
||||
/// kind. An admin who can archive a team or hand it away is an admin who can take it from the person
|
||||
/// who promoted them, and nothing about the response would say so. An invitation claimed on an address
|
||||
/// the identity provider never vouched for is a way into somebody else's team, and every other part of
|
||||
/// that request succeeds. Neither has a symptom; each has a test.
|
||||
/// who promoted them, and nothing about the response would say so. A team that could be joined by
|
||||
/// whoever turns up holding a token asserting an address is a team anybody who can obtain such a token
|
||||
/// is in, and every other part of that request succeeds. Neither has a symptom; each has a test.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
@@ -709,7 +709,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
/// An account exists from its owner's first authenticated request and publishes no key until they
|
||||
/// enroll, and the directory omits it for that whole window — deliberately, because an entry exists
|
||||
/// to be wrapped to. So the lookup is asserted empty first: that is not a missing account, and a
|
||||
/// caller that read it as one would invite an address that already has one.
|
||||
/// caller that read it as one would report an absence to somebody who is standing right there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Adding by address is what covers the gap, and the member it produces says <c>IsEnrolled</c>
|
||||
@@ -749,8 +749,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
|
||||
/// <remarks>
|
||||
/// The other side of it. An address with no account is refused under its own code rather than the
|
||||
/// general one, because the caller can act on it unprompted — there is nobody to add, so invite
|
||||
/// them — and a code shared with a rejected role would leave them guessing which had happened.
|
||||
/// general one, because it is the one refusal on this path that is not about the request — a code
|
||||
/// shared with a rejected role would have the caller checking what they typed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAddressWithNoAccount_IsRefusedWithItsOwnCode()
|
||||
@@ -928,263 +928,35 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1));
|
||||
}
|
||||
|
||||
// ---- Invitations ----
|
||||
|
||||
/// <remarks>
|
||||
/// The expiry is asserted rather than merely present. An invitation that never lapsed would be a
|
||||
/// standing offer against an address, and company addresses are handed to the next person to hold
|
||||
/// the job — so the person who inherits the mailbox would inherit the team.
|
||||
/// <b>The refusal is the end of the road, and this is what pins that.</b> A team was once joinable
|
||||
/// by an address the server had never seen: an invitation row waited, and the next account to sign
|
||||
/// in with that address became a member on the strength of its token's email claim. Adding somebody
|
||||
/// who is not here is now refused outright, and — the half that would be easy to lose — signing in
|
||||
/// afterwards joins nothing. Without the second act this test would pass against a server that had
|
||||
/// merely renamed the deferred path.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry()
|
||||
public async Task AnAddressRefusedForHavingNoAccount_JoinsNothingWhenItLaterSignsIn()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-owner");
|
||||
var owner = await EnrolledClientAsync("no-deferred-owner", "ndowner@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "No deferred joins");
|
||||
var address = NewAddress();
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Invitations");
|
||||
var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin);
|
||||
var refused = await owner.PostContractAsync(
|
||||
MembersUrl(team.TeamId),
|
||||
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, address));
|
||||
|
||||
var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId);
|
||||
await ShouldBeProblemAsync(refused, HttpStatusCode.NotFound, ProblemCodes.NoSuchAccount);
|
||||
|
||||
listed.Email.ShouldBe(address);
|
||||
listed.Role.ShouldBe(TeamMemberRole.Admin);
|
||||
listed.State.ShouldBe(TeamInvitationState.Pending);
|
||||
listed.AcceptedAt.ShouldBeNull();
|
||||
(listed.ExpiresAt - listed.CreatedAt).ShouldBe(TimeSpan.FromDays(14));
|
||||
}
|
||||
var (arrival, userId) = await SignInAsync(address);
|
||||
|
||||
/// <remarks>
|
||||
/// The same body twice, as a client whose response was lost would send it — the shape team and
|
||||
/// vault creation already have. Two invitations to one address would show the same person twice on
|
||||
/// the teams screen and take two revocations to withdraw.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RepeatingAnInvitation_ReturnsTheSameOneRatherThanASecond()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-retry-owner");
|
||||
var address = NewAddress();
|
||||
(await ReadAsync<IReadOnlyList<TeamSummary>>(arrival, TeamsUrl))
|
||||
.ShouldNotContain(row => row.TeamId == team.TeamId);
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Retried invitations");
|
||||
|
||||
var request = new CreateTeamInvitationRequest(
|
||||
Guid.CreateVersion7(), address, TeamMemberRole.Member);
|
||||
|
||||
var first = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
|
||||
owner, InvitationsUrl(team.TeamId), request);
|
||||
|
||||
var second = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
|
||||
owner, InvitationsUrl(team.TeamId), request);
|
||||
|
||||
second.InvitationId.ShouldBe(first.InvitationId);
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
|
||||
owner, InvitationsUrl(team.TeamId));
|
||||
|
||||
// Case-insensitively, because the column is citext and two addresses differing only in case
|
||||
// are one address — a second row under a different casing would still be a second invitation.
|
||||
listed.Count(row => string.Equals(row.Email, address, StringComparison.OrdinalIgnoreCase))
|
||||
.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASecondInvitationToTheSameAddress_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-duplicate-owner");
|
||||
var address = NewAddress();
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Duplicate invitations");
|
||||
|
||||
await InviteAsync(owner, team.TeamId, address);
|
||||
|
||||
// A different id, so this is a second invitation rather than a retry of the first.
|
||||
var response = await owner.PostContractAsync(
|
||||
InvitationsUrl(team.TeamId),
|
||||
new CreateTeamInvitationRequest(Guid.CreateVersion7(), address, TeamMemberRole.Admin));
|
||||
|
||||
await ShouldBeProblemAsync(
|
||||
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Ownership is sole and is handed over deliberately. An invitation that conferred it would let an
|
||||
/// address typed once take the team the moment somebody signed in with it — and the invitee is by
|
||||
/// definition somebody nobody here has met.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task InvitingSomebodyAsOwner_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-owner-role-owner");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Not for sale");
|
||||
|
||||
var response = await owner.PostContractAsync(
|
||||
InvitationsUrl(team.TeamId),
|
||||
new CreateTeamInvitationRequest(
|
||||
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Owner));
|
||||
|
||||
await ShouldBeProblemAsync(
|
||||
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AMalformedAddress_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-malformed-owner");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Shapes");
|
||||
|
||||
var response = await owner.PostContractAsync(
|
||||
InvitationsUrl(team.TeamId),
|
||||
new CreateTeamInvitationRequest(
|
||||
Guid.CreateVersion7(), "not an address", TeamMemberRole.Member));
|
||||
|
||||
await ShouldBeProblemAsync(
|
||||
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A withdrawn invitation stays in the listing rather than vanishing, so the screen can show that it
|
||||
/// was withdrawn rather than letting it read as never sent. Withdrawing it twice is 404, for the
|
||||
/// reason revoking a device grant gives: a caller driving towards "that invitation will not let
|
||||
/// anybody in" can treat 404 as having arrived.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RevokingAnInvitation_MarksItRevokedAndCannotBeDoneTwice()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-revoke-owner");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Withdrawals");
|
||||
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
|
||||
|
||||
var revoked = await DeleteAsync(
|
||||
owner, InvitationUrl(team.TeamId, invitation.InvitationId));
|
||||
|
||||
revoked.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
|
||||
|
||||
listed.State.ShouldBe(TeamInvitationState.Revoked);
|
||||
|
||||
var again = await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
|
||||
|
||||
again.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An invitation is a fact about the team, so any member may read the list — whoever is about to be
|
||||
/// handed a vault key needs to see who else is on their way in — but only an admin may write one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APlainMember_MayReadInvitationsAndMayNotIssueThem()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-reader-owner", "irowner@example.com");
|
||||
var member = await EnrolledClientAsync("invite-reader-member", "irmember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Readable invitations");
|
||||
var entry = await LookupAsync(owner, "irmember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
|
||||
member, InvitationsUrl(team.TeamId));
|
||||
|
||||
listed.ShouldContain(row => row.InvitationId == invitation.InvitationId);
|
||||
|
||||
var refused = await member.PostContractAsync(
|
||||
InvitationsUrl(team.TeamId),
|
||||
new CreateTeamInvitationRequest(
|
||||
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Member));
|
||||
|
||||
await ShouldBeProblemAsync(refused, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
||||
}
|
||||
|
||||
// ---- Claiming an invitation at sign-in ----
|
||||
|
||||
/// <remarks>
|
||||
/// The heart of the feature, and the only path by which an invitation becomes anything. There is no
|
||||
/// token and no mail: the row says "the next account to sign in with this address joins this team",
|
||||
/// and just-in-time provisioning is what reads it. What it creates is a membership and not a key —
|
||||
/// the vault key still has to be wrapped to them from a machine that holds one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SigningInWithAnInvitedAddress_JoinsTheTeamAndMarksTheInvitationAccepted()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("claim-owner");
|
||||
var address = NewAddress();
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Claimed");
|
||||
var invitation = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Member);
|
||||
|
||||
var (invitee, inviteeUserId) = await SignInAsync(address);
|
||||
|
||||
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl))
|
||||
.Where(row => row.TeamId == team.TeamId)
|
||||
.ShouldHaveSingleItem()
|
||||
.Role.ShouldBe(TeamMemberRole.Member);
|
||||
|
||||
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
|
||||
owner, MembersUrl(team.TeamId));
|
||||
|
||||
var joined = members.Where(row => row.UserId == inviteeUserId).ShouldHaveSingleItem();
|
||||
|
||||
joined.Status.ShouldBe(TeamMemberStatus.Active);
|
||||
joined.Role.ShouldBe(TeamMemberRole.Member);
|
||||
joined.IsEnrolled.ShouldBeFalse("membership is authorization, and they hold no key yet");
|
||||
|
||||
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
|
||||
|
||||
listed.State.ShouldBe(TeamInvitationState.Accepted);
|
||||
listed.AcceptedAt.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <b>The security test this feature stands on.</b> An invitation is authorization: claiming one is
|
||||
/// what decides that this server will serve somebody a team's vaults. An address the identity
|
||||
/// provider has not vouched for is an address anybody able to obtain a token can name, so an
|
||||
/// unverified one must confer nothing — that is the same attack <c>AllowEmailLinking</c> exists to
|
||||
/// refuse, arriving by a different door. The failure would be silent by construction: the account is
|
||||
/// provisioned either way and every other part of the request succeeds, so nothing but this would
|
||||
/// notice that the wrong person had walked into the team.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressTheProviderHasNotVerified_ClaimsNothing()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("unverified-owner");
|
||||
var address = NewAddress();
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Verified only");
|
||||
var invitation = await InviteAsync(owner, team.TeamId, address);
|
||||
|
||||
var (impostor, _) = await SignInAsync(address, emailVerified: false);
|
||||
|
||||
(await ReadAsync<IReadOnlyList<TeamSummary>>(impostor, TeamsUrl)).ShouldBeEmpty();
|
||||
|
||||
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
|
||||
|
||||
listed.State.ShouldBe(TeamInvitationState.Pending);
|
||||
listed.AcceptedAt.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A withdrawn invitation is withdrawn, which the claim path has to honour independently — it reads
|
||||
/// the invitation table directly rather than going back through the endpoint that refused.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SigningInAfterAnInvitationWasWithdrawn_JoinsNothing()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("withdrawn-owner");
|
||||
var address = NewAddress();
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Withdrawn");
|
||||
var invitation = await InviteAsync(owner, team.TeamId, address);
|
||||
|
||||
await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
|
||||
|
||||
var (invitee, _) = await SignInAsync(address);
|
||||
|
||||
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl)).ShouldBeEmpty();
|
||||
(await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
|
||||
.ShouldNotContain(row => row.UserId == userId);
|
||||
}
|
||||
|
||||
// ---- Vault key grants ----
|
||||
@@ -1531,17 +1303,12 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
private static string MemberRoleUrl(Guid teamId, Guid userId) =>
|
||||
$"{MemberUrl(teamId, userId)}/role";
|
||||
|
||||
private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations";
|
||||
|
||||
private static string InvitationUrl(Guid teamId, Guid invitationId) =>
|
||||
$"{InvitationsUrl(teamId)}/{invitationId}";
|
||||
|
||||
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
|
||||
|
||||
private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}";
|
||||
|
||||
/// <summary>An address no account holds, uniquified because the container is shared.</summary>
|
||||
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
|
||||
private static string NewAddress() => $"newcomer-{Guid.CreateVersion7():N}@example.com";
|
||||
|
||||
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
|
||||
{
|
||||
@@ -1565,18 +1332,16 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>Signs a brand-new account in, which is what claims any invitation to its address.</summary>
|
||||
/// <summary>Signs a brand-new account in, without enrolling it.</summary>
|
||||
/// <remarks>
|
||||
/// No enrollment, because an invitee has no key and the claim path must not need one — requiring it
|
||||
/// would be requiring it of exactly the person who cannot yet supply it. Driving <c>/me</c> is what
|
||||
/// runs just-in-time provisioning, and provisioning is where the claim happens.
|
||||
/// No enrollment, because the account this stands for is somebody between their first sign-in and
|
||||
/// setting a machine up — the one the directory cannot answer for and who can still be added by
|
||||
/// address. Driving <c>/me</c> is what runs just-in-time provisioning, which is what creates the
|
||||
/// account at all.
|
||||
/// </remarks>
|
||||
private async Task<(HttpClient Client, Guid UserId)> SignInAsync(
|
||||
string email,
|
||||
bool emailVerified = true)
|
||||
private async Task<(HttpClient Client, Guid UserId)> SignInAsync(string email)
|
||||
{
|
||||
var client = fixture.CreateClientFor(
|
||||
$"invitee-{Guid.CreateVersion7():N}", email, emailVerified);
|
||||
var client = fixture.CreateClientFor($"newcomer-{Guid.CreateVersion7():N}", email);
|
||||
|
||||
var me = await ReadAsync<MeResponse>(client, MeUrl);
|
||||
|
||||
@@ -1675,32 +1440,6 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static Task<TeamInvitationSummary> InviteAsync(
|
||||
HttpClient client,
|
||||
Guid teamId,
|
||||
string email,
|
||||
TeamMemberRole role = TeamMemberRole.Member) =>
|
||||
PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
|
||||
client,
|
||||
InvitationsUrl(teamId),
|
||||
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, role));
|
||||
|
||||
/// <remarks>
|
||||
/// Filtered by id rather than taken from a position in the list, because the container is shared and
|
||||
/// every other class's invitations are in the same table. A count over the whole listing would pass
|
||||
/// or fail depending on what else ran.
|
||||
/// </remarks>
|
||||
private static async Task<TeamInvitationSummary> FindInvitationAsync(
|
||||
HttpClient client,
|
||||
Guid teamId,
|
||||
Guid invitationId)
|
||||
{
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
|
||||
client, InvitationsUrl(teamId));
|
||||
|
||||
return listed.Where(row => row.InvitationId == invitationId).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
|
||||
HttpClient client,
|
||||
string url,
|
||||
|
||||
Reference in New Issue
Block a user