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:
jaap-jan committed 2026-08-05 08:28:57 +02:00
1 parent 7dc3b8950d
commit 69bc9e270b
39 files changed
+2258 -2275

No files matched your search

@@ -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