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:
2026-08-05 08:28:57 +02:00
parent 7dc3b8950d
commit 69bc9e270b
39 changed files with 2258 additions and 2275 deletions
+10 -66
View File
@@ -33,9 +33,6 @@ public sealed class Team
/// <summary>Members.</summary>
public ICollection<TeamMembership> Memberships { get; } = [];
/// <summary>Invitations to addresses that have no account here yet.</summary>
public ICollection<TeamInvitation> Invitations { get; } = [];
}
/// <summary>
@@ -68,10 +65,18 @@ public sealed class TeamMembership
/// <summary>Membership state.</summary>
public MembershipStatus Status { get; set; }
/// <summary>Who invited them.</summary>
/// <summary>
/// Which admin added them.
/// </summary>
/// <remarks>
/// Named for the invitations that used to be the other way in. They are gone — an account is added
/// by somebody who names it, and there is no path that creates a membership out of a token's email
/// claim — so this now records only that, and the column keeps its name rather than costing a
/// migration to rename a field nothing reads but an audit trail.
/// </remarks>
public Guid? InvitedByUserId { get; set; }
/// <summary>When the invitation was accepted.</summary>
/// <summary>When the membership became active.</summary>
public DateTimeOffset? JoinedAtUtc { get; set; }
/// <summary>Creation timestamp.</summary>
@@ -81,64 +86,3 @@ public sealed class TeamMembership
public DateTimeOffset? DeletedAtUtc { get; set; }
}
/// <summary>
/// A standing offer of membership to an email address that has no account here yet.
/// </summary>
/// <remarks>
/// <para>
/// <b>Its own table rather than a <see cref="TeamMembership"/> with
/// <see cref="MembershipStatus.Invited"/>.</b> A membership names an account —
/// <c>team_membership.user_id</c> is not nullable and carries a foreign key to
/// <see cref="UserAccount"/> — so an invitee who has never signed in has nothing for that row to
/// point at. Widening that column would make the unique index on (team, user) meaningless, because
/// PostgreSQL counts every NULL as distinct, and would silently change what every
/// <c>m.UserId == user.Id</c> query in the server means.
/// </para>
/// <para>
/// <b>There is no token.</b> Nothing is sent, because this server has no outbound mail path; the row
/// is an instruction to the sign-in path rather than a secret somebody presents. That is why it is
/// keyed on the address and why the address has to be one the identity provider marks verified before
/// the claim is honoured — an unclaimable invitation is an inconvenience, but one claimable by
/// anybody who can assert an address is a way into the team.
/// </para>
/// <para>
/// Revoked and accepted rows are retained rather than deleted, as <see cref="VaultKeyGrant"/> is and
/// for the same reason: the uniqueness that matters is among <em>pending</em> invitations, and the
/// history of who invited whom stays resolvable.
/// </para>
/// </remarks>
public sealed class TeamInvitation
{
/// <summary>Primary key.</summary>
public Guid Id { get; set; }
/// <summary>The team.</summary>
public Guid TeamId { get; set; }
/// <summary>The team.</summary>
public Team? Team { get; set; }
/// <summary>The address invited. Case-insensitive.</summary>
public string Email { get; set; } = string.Empty;
/// <summary>Role the membership will carry when it is claimed.</summary>
public TeamRole Role { get; set; }
/// <summary>Who issued it.</summary>
public Guid InvitedByUserId { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>When it stops being claimable.</summary>
public DateTimeOffset ExpiresAtUtc { get; set; }
/// <summary>When an account with this address signed in and took it up.</summary>
public DateTimeOffset? AcceptedAtUtc { get; set; }
/// <summary>Which account took it up.</summary>
public Guid? AcceptedByUserId { get; set; }
/// <summary>Revocation timestamp.</summary>
public DateTimeOffset? RevokedAtUtc { get; set; }
}