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:
@@ -195,35 +195,6 @@ internal sealed record VaultGrantRowViewModel(VaultGrantSummary Grant, uint Vaul
|
||||
Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration;
|
||||
}
|
||||
|
||||
/// <summary>One invitation, as a row under the members it will join.</summary>
|
||||
internal sealed record VaultInvitationRowViewModel(TeamInvitationSummary Invitation)
|
||||
{
|
||||
internal Guid InvitationId => Invitation.InvitationId;
|
||||
|
||||
internal string Email => Invitation.Email;
|
||||
|
||||
internal string Role => Invitation.Role.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// What has become of it, said as a sentence rather than a status word.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pending case has to carry the whole mechanism, because there is nothing else on this screen
|
||||
/// that could: nothing was sent, so somebody reading "invited" would reasonably wait for an email
|
||||
/// that is never coming.
|
||||
/// </remarks>
|
||||
internal string State => Invitation.State switch
|
||||
{
|
||||
TeamInvitationState.Accepted => "joined",
|
||||
TeamInvitationState.Revoked => "withdrawn",
|
||||
TeamInvitationState.Expired => "expired — invite them again if they still need it",
|
||||
_ => "waiting — they join when they first sign in here. Nothing was sent; tell them yourself.",
|
||||
};
|
||||
|
||||
/// <summary>Whether this invitation can still be withdrawn.</summary>
|
||||
internal bool IsPending => Invitation.State == TeamInvitationState.Pending;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A destructive vault operation, armed and waiting to be confirmed.
|
||||
/// </summary>
|
||||
@@ -286,7 +257,7 @@ internal enum VaultActionKind
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The vault list is read from the session and works with no network. Everything under it — members,
|
||||
/// invitations, key holders — is read from the server on selection and after each change, because
|
||||
/// key holders — is read from the server on selection and after each change, because
|
||||
/// membership is not vault content and has no local mirror.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -329,18 +300,12 @@ internal sealed partial class VaultsViewModel(
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultGrantRowViewModel> Grants { get; } = [];
|
||||
|
||||
/// <summary>Invitations to addresses that are not accounts here yet.</summary>
|
||||
internal ObservableCollection<VaultInvitationRowViewModel> Invitations { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultRowViewModel? selectedVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultMemberRowViewModel? selectedMember;
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultInvitationRowViewModel? selectedInvitation;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
@@ -377,10 +342,10 @@ internal sealed partial class VaultsViewModel(
|
||||
// ---- Adding somebody ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string inviteEmail = string.Empty;
|
||||
private string newMemberEmail = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The role a newly added or invited account gets.
|
||||
/// The role a newly added account gets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Member by default, which is the role somebody adding a colleague almost always means. Viewer
|
||||
@@ -461,9 +426,6 @@ internal sealed partial class VaultsViewModel(
|
||||
/// </remarks>
|
||||
internal bool ShowsVaultActions => !IsConfirming;
|
||||
|
||||
/// <summary>Whether the selected vault has any invitation worth drawing a list for.</summary>
|
||||
internal bool HasInvitations => Invitations.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The warning a vault sharing its membership list with others has to carry.
|
||||
/// </summary>
|
||||
@@ -530,7 +492,6 @@ internal sealed partial class VaultsViewModel(
|
||||
if (session() is not { } open)
|
||||
{
|
||||
Members.Clear();
|
||||
Invitations.Clear();
|
||||
Grants.Clear();
|
||||
RaiseState();
|
||||
|
||||
@@ -548,8 +509,7 @@ internal sealed partial class VaultsViewModel(
|
||||
|
||||
// The assignment reselects the same vault through a new row object, so the selection handler
|
||||
// would start its own read of the very lists this method is about to read — two reads clearing
|
||||
// and then appending into the same collections, which draws every member, invitation and key
|
||||
// holder twice. Suppressed rather than deduplicated, because the read below is awaited and the
|
||||
// and then appending into the same collections, which draws every member and key holder twice. Suppressed rather than deduplicated, because the read below is awaited and the
|
||||
// handler's is not: this is the one that has to be the reload's.
|
||||
isReselecting = true;
|
||||
|
||||
@@ -680,8 +640,8 @@ internal sealed partial class VaultsViewModel(
|
||||
/// <para>
|
||||
/// <b>A vault always belongs to a team, and this is what keeps that from being the user's problem.</b>
|
||||
/// Naming a vault is enough: the team is derived from the name, created with this account as its owner,
|
||||
/// and the vault goes into it. What that buys is the rest of this screen — members, roles, invitations
|
||||
/// and key holders all hang off it, so they are all there the moment the vault is.
|
||||
/// and the vault goes into it. What that buys is the rest of this screen — members, roles and key
|
||||
/// holders all hang off it, so they are all there the moment the vault is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two calls, and the first can succeed alone.</b> When it does, the membership list is kept rather
|
||||
@@ -923,10 +883,16 @@ internal sealed partial class VaultsViewModel(
|
||||
/// <para>
|
||||
/// <b>A directory miss is not an absent account, and treating it as one was a bug worth naming.</b>
|
||||
/// The directory returns only accounts that have published a key, so everybody between their first
|
||||
/// sign-in and their enrollment is missing from it. Falling straight through to an invitation told
|
||||
/// somebody who was standing right there that they had no account here, left the members list
|
||||
/// unchanged, and made them wait for a sweep that runs at most hourly. So the miss is retried as an
|
||||
/// add by address, and only a server that says there is no such account reaches the invitation.
|
||||
/// sign-in and their enrollment is missing from it. Reporting that miss as "no account here" told
|
||||
/// somebody who was standing right there that they were not, and left the members list unchanged. So
|
||||
/// the miss is retried as an add by address, and only a server saying there is no such account is
|
||||
/// taken as an answer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And that answer is the end of it.</b> There is nothing to fall through to: a membership is
|
||||
/// granted to an account, so somebody who has never signed in here cannot be added yet, and the
|
||||
/// remedy belongs to them rather than to the person at this screen. Saying so plainly is the whole
|
||||
/// of what this command can do about it — see <c>docs/adr/0009-team-access-model.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
@@ -940,7 +906,7 @@ internal sealed partial class VaultsViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var email = InviteEmail.Trim();
|
||||
var email = NewMemberEmail.Trim();
|
||||
|
||||
if (email.Length == 0)
|
||||
{
|
||||
@@ -948,7 +914,7 @@ internal sealed partial class VaultsViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(() => AddOrInviteAsync(server, teamId, email, cancellationToken))
|
||||
await RunAsync(() => AddAsync(server, teamId, email, cancellationToken))
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -961,7 +927,7 @@ internal sealed partial class VaultsViewModel(
|
||||
: "Select a vault on the left first — somebody is added to one vault, not to all.";
|
||||
|
||||
/// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary>
|
||||
private async Task AddOrInviteAsync(
|
||||
private async Task AddAsync(
|
||||
IVaultServer server,
|
||||
Guid teamId,
|
||||
string email,
|
||||
@@ -985,13 +951,17 @@ internal sealed partial class VaultsViewModel(
|
||||
catch (DodoSshApiException exception)
|
||||
when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
|
||||
{
|
||||
// The address really is unknown here, which only the server can say. This is the one
|
||||
// route to an invitation, and it is now a fact rather than an inference from silence.
|
||||
await InviteAsync(server, teamId, email, cancellationToken).ConfigureAwait(true);
|
||||
// The address really is unknown here, which only the server can say. Reported rather than
|
||||
// rethrown, because it is the answer rather than a failure — and the sentence has to carry
|
||||
// what happens next, or somebody retypes the address expecting a different outcome.
|
||||
Status = $"No account on this server uses '{email}'. Ask them to sign in here once, "
|
||||
+ "which is what creates the account, and then add them. Nothing is held for them in "
|
||||
+ "the meantime — an address is not a way into a vault.";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
NewMemberEmail = string.Empty;
|
||||
|
||||
// Before the reload, so the vault list this screen redraws already shows what they can open. The
|
||||
// sharing is what makes the membership worth anything, and doing it here rather than leaving a
|
||||
@@ -1127,73 +1097,7 @@ internal sealed partial class VaultsViewModel(
|
||||
: $"Added {who}. {shared}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invites an address the directory does not know.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Reached by falling through from <see cref="AddMemberAsync"/> rather than from a second button,
|
||||
/// because the person typing an address does not know or care which of the two applies — that is a
|
||||
/// fact about the server's account table, not about what they are trying to do. Which one happened
|
||||
/// is reported afterwards, because the difference decides what they have to do next.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
|
||||
/// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
|
||||
/// better.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task InviteAsync(
|
||||
IVaultServer server,
|
||||
Guid teamId,
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invitation = await server.Teams
|
||||
.CreateTeamInvitationAsync(
|
||||
teamId,
|
||||
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"No account here has the address '{email}' yet, so it has been invited instead. "
|
||||
+ $"They join this vault as {invitation.Role.ToString().ToLowerInvariant()} the first time "
|
||||
+ "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — "
|
||||
+ "and their identity provider has to confirm the address is theirs.";
|
||||
}
|
||||
|
||||
/// <summary>Withdraws an invitation that has not been taken up.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedVault?.TeamId is not { } teamId
|
||||
|| SelectedInvitation is not { } invitation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var revoked = await server.Teams
|
||||
.RevokeTeamInvitationAsync(teamId, invitation.InvitationId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = revoked
|
||||
? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them "
|
||||
+ "in this vault."
|
||||
: $"The invitation to {invitation.Email} was already taken up or withdrawn. If they "
|
||||
+ "are a member now, remove them instead.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Picks the role a newly added or invited account will get.</summary>
|
||||
/// <summary>Picks the role a newly added account will get.</summary>
|
||||
[RelayCommand]
|
||||
private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;
|
||||
|
||||
@@ -1695,17 +1599,14 @@ internal sealed partial class VaultsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the selected vault's members, invitations and key holders.</summary>
|
||||
/// <summary>Reads the selected vault's members and key holders.</summary>
|
||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var generation = ++selectionGeneration;
|
||||
|
||||
Members.Clear();
|
||||
Invitations.Clear();
|
||||
Grants.Clear();
|
||||
|
||||
OnPropertyChanged(nameof(HasInvitations));
|
||||
|
||||
if (connection() is not { } server || SelectedVault is not { } vault)
|
||||
{
|
||||
return;
|
||||
@@ -1735,24 +1636,6 @@ internal sealed partial class VaultsViewModel(
|
||||
{
|
||||
Members.Add(new VaultMemberRowViewModel(member, member.UserId == selfId));
|
||||
}
|
||||
|
||||
var invitations = await server.Teams
|
||||
.ListTeamInvitationsAsync(teamId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (generation != selectionGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var invitation in invitations)
|
||||
{
|
||||
Invitations.Add(new VaultInvitationRowViewModel(invitation));
|
||||
}
|
||||
|
||||
SelectedInvitation = Invitations.FirstOrDefault(row => row.IsPending);
|
||||
|
||||
OnPropertyChanged(nameof(HasInvitations));
|
||||
}
|
||||
|
||||
/// <summary>Tells the shell that the set of vaults, or one of their names, has moved.</summary>
|
||||
@@ -1768,7 +1651,6 @@ internal sealed partial class VaultsViewModel(
|
||||
OnPropertyChanged(nameof(SelectedIsShared));
|
||||
OnPropertyChanged(nameof(SelectedIsPersonal));
|
||||
OnPropertyChanged(nameof(CanDeleteSelected));
|
||||
OnPropertyChanged(nameof(HasInvitations));
|
||||
OnPropertyChanged(nameof(SharedMembershipWarning));
|
||||
OnPropertyChanged(nameof(HasSharedMembershipWarning));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
|
||||
Reference in New Issue
Block a user