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
@@ -49,9 +49,6 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
[JsonSerializable(typeof(AddTeamMemberRequest))]
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
[JsonSerializable(typeof(CreateTeamInvitationRequest))]
[JsonSerializable(typeof(TeamInvitationSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(UpdateVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
+12 -18
View File
@@ -135,30 +135,24 @@ public static class ProblemCodes
/// <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.
/// that is not about the request: the request was fine and named somebody who is not here. A client
/// that could not tell this apart from a rejected role would have to say "check what you typed"
/// about an address that was typed correctly.
/// </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.
/// <b>Nothing follows it.</b> This used to be the signal to invite the address instead; there are
/// no invitations, and a membership is only ever granted to an account somebody named, so the whole
/// of what a client can do with this is say that the person has to sign in here once. See
/// <see cref="AddTeamMemberRequest"/>.
/// </para>
/// <para>
/// It answers whether an address has an account here, which is a real disclosure and a bounded one:
/// only an admin or owner of the team the add names reaches it — the endpoint checks that first —
/// and it is the same fact the member list would show them a moment later.
/// </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.
/// </summary>
/// <remarks>
/// Separate from <see cref="InvalidTeam"/> because the most common cause has its own remedy that a
/// client can act on — an address that already has an account should be added through the
/// directory instead, which is the path that shows the caller the key they are about to trust.
/// </remarks>
public const string InvalidTeamInvitation = "invalid-team-invitation";
/// <summary>
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
@@ -10,7 +10,6 @@ const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeamInvitation = "invalid-team-invitation" -> string!
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!
@@ -51,17 +50,6 @@ DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest
DodoSSH.Contracts.CreateTeamInvitationRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamInvitationRequest!
DodoSSH.Contracts.CreateTeamInvitationRequest.CreateTeamInvitationRequest(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.get -> string!
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(DodoSSH.Contracts.CreateTeamInvitationRequest? other) -> bool
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.get -> System.Guid
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamRequest
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
@@ -594,33 +582,6 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
DodoSSH.Contracts.SyncPushResult.Version.get -> int?
DodoSSH.Contracts.SyncPushResult.Version.init -> void
DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Accepted = 2 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Expired = 4 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Pending = 1 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Revoked = 3 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Unspecified = 0 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary
DodoSSH.Contracts.TeamInvitationSummary.<Clone>$() -> DodoSSH.Contracts.TeamInvitationSummary!
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamInvitationState State, out System.Guid InvitedByUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset ExpiresAt, out System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamInvitationSummary.Email.get -> string!
DodoSSH.Contracts.TeamInvitationSummary.Email.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Equals(DodoSSH.Contracts.TeamInvitationSummary? other) -> bool
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamInvitationSummary.Role.init -> void
DodoSSH.Contracts.TeamInvitationSummary.State.get -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary.State.init -> void
DodoSSH.Contracts.TeamInvitationSummary.TeamInvitationSummary(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamInvitationState State, System.Guid InvitedByUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset ExpiresAt, System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
@@ -793,9 +754,6 @@ override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamInvitationRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamInvitationRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
@@ -889,9 +847,6 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
override DodoSSH.Contracts.TeamInvitationSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamInvitationSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamInvitationSummary.ToString() -> string!
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
@@ -926,8 +881,6 @@ static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddT
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator !=(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator ==(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
@@ -993,8 +946,6 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator !=(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator ==(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
+19 -102
View File
@@ -50,16 +50,15 @@ public enum TeamMemberStatus
/// </summary>
/// <remarks>
/// <para>
/// Still nothing writes this, and invitations shipping is the reason rather than an exception to
/// it. A membership names an account: <c>team_membership.user_id</c> is not nullable and carries a
/// foreign key, so somebody who has never signed in has nothing for that row to point at. An
/// invitation is therefore its own record against an <em>address</em>
/// (<see cref="TeamInvitationSummary"/>), and it becomes a membership at
/// <see cref="Active"/> the moment an account with that address first signs in.
/// <b>Nothing writes this, and nothing in this server can.</b> A membership names an account —
/// <c>team_membership.user_id</c> is not nullable and carries a foreign key — so a row in this
/// state would have to point at somebody who has never signed in. There was a separate invitation
/// record that stood in for exactly that, and it is gone: an address is not a way into a team, and
/// only an account that exists can be added. See <c>docs/adr/0009-team-access-model.md</c>.
/// </para>
/// <para>
/// Retained because the column exists and a client must not fail on a value a later server may
/// send — a server that grew a second invitation model would use it.
/// Retained because the column exists and holds this value in nobody's database, and because a
/// client must not fail on a value a later server may send.
/// </para>
/// </remarks>
Invited = 1,
@@ -224,8 +223,16 @@ public sealed record TeamMemberSummary(
/// 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.
/// all, and a caller reading the directory's silence as "no account here" would report an absence to
/// somebody who is standing right there.
/// </para>
/// <para>
/// <b>This is the only way into a team.</b> There is no invitation and no address-based path — a
/// membership is granted to an account that already exists, named by somebody who can see it. That
/// rules out the shape where a team is joined by whoever turns up holding a token asserting an
/// address, which is the same attack <c>OidcOptions.AllowEmailLinking</c> refuses one door along. The
/// cost is stated rather than hidden: somebody who has never signed in here cannot be added yet, and
/// the refusal says so. See <c>docs/adr/0009-team-access-model.md</c>.
/// </para>
/// <para>
/// No key is verified on this path, and none needs to be: nothing is wrapped by adding somebody. The
@@ -241,7 +248,8 @@ public sealed record TeamMemberSummary(
/// <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.
/// with <see cref="ProblemCodes.NoSuchAccount"/>, which is the end of the road rather than a step on
/// it: the remedy is that person signing in once, and it belongs to them rather than to the caller.
/// </param>
public sealed record AddTeamMemberRequest(
Guid UserId,
@@ -252,97 +260,6 @@ public sealed record AddTeamMemberRequest(
/// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
/// <summary>What has become of an invitation.</summary>
/// <remarks>
/// Derived from the invitation's own timestamps rather than stored, so — unlike every other enum in
/// this file — it has no <c>DodoSSH.Domain</c> twin and no numbering to keep in step. That is the
/// point of computing it: <see cref="Expired"/> is a fact about the clock, and a stored state would
/// have to be swept by something that remembered to run.
/// </remarks>
public enum TeamInvitationState
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Waiting. It becomes a membership when an account with this address signs in.</summary>
Pending = 1,
/// <summary>Taken up. The address signed in and is now a member.</summary>
Accepted = 2,
/// <summary>Withdrawn before it was taken up.</summary>
Revoked = 3,
/// <summary>Its lifetime ran out. It will not become a membership.</summary>
Expired = 4,
}
/// <summary>Invites an address that has no account here yet.</summary>
/// <remarks>
/// <para>
/// <b>By email, where <see cref="AddTeamMemberRequest"/> is by user id.</b> Adding a member resolves an
/// address through the directory first, so the caller sees the public key they are about to wrap a
/// vault to. An invitation cannot do that — there may be no account and therefore no key — so it grants
/// nothing readable and is never a step towards sharing.
/// </para>
/// <para>
/// <b>An address that already has an account is accepted rather than refused</b>, and only an address
/// already belonging to a member of this team is turned away. Refusing on the strength of an account
/// existing would make this endpoint an oracle for which addresses have accounts here, answerable by
/// anybody willing to create a team first — and it would be answering a question the caller did not
/// ask. Whether the account exists changes only how soon the invitation is taken up: an existing one
/// picks it up on its next request.
/// </para>
/// <para>
/// <b>There is no token and nothing is sent.</b> This server has no outbound mail path, so the
/// invitation is not a link: it is a standing instruction that the next account to sign in with this
/// address joins the team. Telling them to sign in is the caller's job, over a channel this server
/// does not carry. That also means the address has to be one the identity provider will assert and
/// mark verified — an unverified email is refused at claim time, because an invitation that anybody
/// could take by naming somebody else's address is a way in.
/// </para>
/// </remarks>
/// <param name="InvitationId">
/// Client-generated UUIDv7, for the reason a team id is client-generated: a create whose response was
/// lost can be re-sent verbatim rather than leaving two invitations to the same address.
/// </param>
/// <param name="Email">The address to invite. Matched case-insensitively.</param>
/// <param name="Role">
/// Role to grant on arrival. May not be <see cref="TeamMemberRole.Owner"/> — ownership is sole and is
/// handed over deliberately, never conferred by an address signing in.
/// </param>
public sealed record CreateTeamInvitationRequest(
Guid InvitationId,
string Email,
TeamMemberRole Role);
/// <summary>One invitation, as the teams interface sees it.</summary>
/// <remarks>
/// The address is in plaintext here, as it is on <see cref="TeamMemberSummary"/>. It is readable by
/// the team's members, who are the people it concerns; the server stores it in plaintext either way
/// and docs/crypto.md §10 already records that membership metadata is not encrypted.
/// </remarks>
/// <param name="InvitationId">The invitation.</param>
/// <param name="Email">The address invited.</param>
/// <param name="Role">The role it will grant.</param>
/// <param name="State">What has become of it.</param>
/// <param name="InvitedByUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="ExpiresAt">
/// When it stops being claimable. An invitation that never expired would be a standing offer on an
/// address somebody may hand on or lose.
/// </param>
/// <param name="AcceptedAt">When an account with this address signed in and took it up, if one has.</param>
public sealed record TeamInvitationSummary(
Guid InvitationId,
string Email,
TeamMemberRole Role,
TeamInvitationState State,
Guid InvitedByUserId,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
DateTimeOffset? AcceptedAt);
/// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary>