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
@@ -17,51 +17,27 @@ public interface ICurrentUserContext
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Turns pending team invitations addressed to a verified email into memberships.
/// </summary>
/// <remarks>
/// <para>
/// Declared here, beside its only caller, and implemented in <c>Features/Teams</c>. The direction is
/// deliberate: sign-in is what an invitation waits for, so the sign-in path names the shape it needs
/// and the teams feature supplies it — rather than <see cref="ICurrentUserContext"/>, which every
/// endpoint in the server depends on, growing a reference into one feature's folder.
/// </para>
/// </remarks>
public interface ITeamInvitationClaim
{
/// <summary>
/// Claims every live invitation addressed to <paramref name="email"/> for this account.
/// </summary>
/// <param name="user">The account signing in.</param>
/// <param name="email">The address the token asserted, or null if it asserted none.</param>
/// <param name="emailVerified">
/// Whether the provider marked that address verified. False refuses the claim outright and is the
/// whole of what stops an invitation being taken by anybody able to assert somebody else's
/// address.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>How many invitations became memberships.</returns>
Task<int> ClaimAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken);
}
/// <summary>
/// Request-scoped caller identity with just-in-time provisioning.
/// </summary>
/// <remarks>
/// <para>
/// Identity is keyed on <c>(issuer, subject)</c>, never on email. Matching an existing account by
/// email means anyone who can obtain a token bearing a victim's email address — from any configured
/// provider — inherits that victim's vaults, so it is opt-in configuration and off by default.
/// </para>
/// <para>
/// <b>Nothing here reads the email claim for authorization, and there is deliberately no hook left for
/// anything that would.</b> This class used to claim pending team invitations on the way past, which
/// made a provider's assertion about an address into a decision about who joins a team; invitations are
/// gone and membership is granted only to an account somebody named — see
/// <c>docs/adr/0009-team-access-model.md</c>. The address is still recorded, for display.
/// </para>
/// </remarks>
internal sealed class CurrentUserContext(
IHttpContextAccessor accessor,
DodoDbContext database,
IOptions<Setup.OidcOptions> oidcOptions,
ITeamInvitationClaim invitations,
TimeProvider clock)
: ICurrentUserContext
{
@@ -73,8 +49,7 @@ internal sealed class CurrentUserContext(
/// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries
/// the xmin concurrency token — would start losing races between a user's own overlapping
/// requests. Writing it never is what made the old "last active" column impossible to offer
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not",
/// and it is also the window on which a pending invitation is swept for.
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not".
/// </remarks>
private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);
@@ -101,7 +76,6 @@ internal sealed class CurrentUserContext(
var options = oidcOptions.Value;
var email = principal.FindFirstValue(options.EmailClaim);
var displayName = principal.FindFirstValue(options.NameClaim);
var emailVerified = IsVerified(principal, options.EmailVerifiedClaim);
var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
@@ -110,52 +84,28 @@ internal sealed class CurrentUserContext(
cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
.ConfigureAwait(false);
// A first sign-in is exactly what an invitation is waiting for, so it is claimed at once
// rather than on the next hourly sweep — which would leave somebody staring at a team
// list that does not yet contain the team they were told they had been added to.
await invitations
.ClaimAsync(cached, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
return cached;
}
cached = existing;
await RefreshLastSeenAsync(existing, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
await RefreshLastSeenAsync(existing, cancellationToken).ConfigureAwait(false);
return cached;
}
/// <summary>
/// Records that this account is active, and sweeps for invitations it can now claim.
/// Records that this account is active.
/// </summary>
/// <remarks>
/// <para>
/// The two are one operation because they want the same rate. Both are housekeeping nobody is
/// waiting on, and doing them together costs one extra round trip per account per hour rather
/// than two.
/// </para>
/// <para>
/// The sweep is what makes claiming recoverable rather than one-shot. A claim that failed at
/// provisioning — or an invitation issued in the window between an account being created and this
/// person next signing in — is picked up here instead of being stranded for ever.
/// </para>
/// <para>
/// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
/// </para>
/// </remarks>
private async Task RefreshLastSeenAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken)
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
@@ -171,20 +121,8 @@ internal sealed class CurrentUserContext(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken)
.ConfigureAwait(false);
await invitations
.ClaimAsync(user, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
}
/// <remarks>
/// A JWT boolean arrives as the string "true", so this parses rather than compares against a
/// constant. Anything else — absent, "false", or a value this does not understand — is false,
/// because the failure that matters is treating an unverified address as verified.
/// </remarks>
private static bool IsVerified(ClaimsPrincipal principal, string claimType) =>
bool.TryParse(principal.FindFirstValue(claimType), out var verified) && verified;
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
database.Users.SingleOrDefaultAsync(
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
@@ -495,173 +495,6 @@ internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser,
}
}
/// <summary>Lists a team's invitations.</summary>
/// <remarks>
/// Readable by every member, as the members list is: whoever is about to be handed a vault key needs
/// to see who else is on their way in. Accepted and withdrawn invitations are included so the screen
/// can say an invitation was taken up rather than letting it vanish and read as never sent.
/// </remarks>
internal sealed class ListTeamInvitationsEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ListTeamInvitations")
.WithSummary("Lists a team's invitations.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await invitations.ListAsync(teamId, ct).ConfigureAwait(false));
}
}
/// <summary>Invites an address to a team.</summary>
/// <remarks>
/// Authenticated rather than Enrolled, and pointedly so. Every other write that ends in somebody
/// reading a vault needs a key of the caller's own; this one does not, because an invitation grants
/// membership and membership is not readability. Requiring enrollment here would also be requiring it
/// of the wrong person — the invitee is the one with no key, and they have no account yet either.
/// </remarks>
internal sealed class CreateTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: Endpoint<CreateTeamInvitationRequest, Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("CreateTeamInvitation")
.WithSummary("Invites an email address to a team.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
CreateTeamInvitationRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can invite people to it.");
}
try
{
var invitation = await invitations
.CreateAsync(user, teamId, req, ct)
.ConfigureAwait(false);
return TypedResults.Ok(invitation);
}
catch (TeamInvitationInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.InvalidTeamInvitation,
exception.Message);
}
}
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
/// <remarks>
/// 404 for an invitation that is not there, is not this team's, or has already been claimed — the
/// same answer for all three, and 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. A claimed one
/// is a membership now, and removing a member is a different operation with different consequences.
/// </remarks>
internal sealed class RevokeTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/teams/{teamId:guid}/invitations/{invitationId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("RevokeTeamInvitation")
.WithSummary("Withdraws an invitation that has not been taken up.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var invitationId = Route<Guid>("invitationId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can withdraw an invitation.");
}
var revoked = await invitations
.RevokeAsync(user, teamId, invitationId, ct)
.ConfigureAwait(false);
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
}
}
/// <summary>Creates a vault owned by a team.</summary>
internal sealed class CreateTeamVaultEndpoint(
ICurrentUserContext currentUser,
@@ -36,20 +36,19 @@ internal sealed class TeamNotEmptyException(string message) : Exception(message)
/// <summary>The address given to an add has no account on this server.</summary>
/// <remarks>
/// Separate from <see cref="TeamInvalidException"/> because the caller can act on it without being
/// told to: there is nobody to add, so the address is invited instead. Folded into the general code it
/// would be indistinguishable from a rejected role, and a client would have to guess which it was.
/// Separate from <see cref="TeamInvalidException"/> because it is the one refusal on this path that is
/// not about the request: the request was well formed and named somebody who is not here. Folded into
/// the general code it would be indistinguishable from a rejected role, and a client wanting to say
/// "ask them to sign in first" would have to guess which of the two it had.
/// <para>
/// It is the end of the road rather than a step on it. Membership is only ever granted to an account
/// that exists — see <c>docs/adr/0009-team-access-model.md</c> — so there is nothing else for a client
/// to try, and the honest answer is to name the address and say what has to happen before it can be
/// added.
/// </para>
/// </remarks>
internal sealed class NoSuchAccountException(string message) : Exception(message);
/// <summary>An invitation was rejected.</summary>
/// <remarks>
/// Separate from <see cref="TeamInvalidException"/> because its commonest cause has a different
/// remedy: an address that already has an account here should be added through the directory, which
/// is the path that shows the caller the public key they are about to trust.
/// </remarks>
internal sealed class TeamInvitationInvalidException(string message) : Exception(message);
/// <summary>
/// A vault key grant was rejected.
/// </summary>
@@ -1,488 +0,0 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Invitations to addresses that have no account here yet, and the sign-in path that claims them.
/// </summary>
/// <remarks>
/// <para>
/// <b>An invitation is a standing instruction, not a message and not a token.</b> This server has no
/// outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row
/// says "the next account to sign in with this address joins this team as this role", and telling
/// them to sign in is the caller's job over a channel this server does not carry. That is a smaller
/// feature than the design drew, and it is the whole of what can be built honestly without a mail
/// path — a link nobody can deliver would be worse than none.
/// </para>
/// <para>
/// <b>Verification is the security boundary, and it is the only one.</b> Membership is authorization
/// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the
/// identity provider has not marked verified would let anybody who can get a token asserting somebody
/// else's address walk into their team — which is precisely the attack
/// <c>OidcOptions.AllowEmailLinking</c> exists to refuse. So an unverified address claims nothing,
/// there is no setting that relaxes it, and the refusal is logged rather than silent.
/// </para>
/// <para>
/// What an invitation still cannot do is make anything readable. It creates a membership, and a
/// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine
/// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves
/// the first half of it earlier.
/// </para>
/// </remarks>
internal sealed class TeamInvitationService(
DodoDbContext database,
TimeProvider clock,
ILogger<TeamInvitationService> logger)
: ITeamInvitationClaim
{
/// <summary>Longest acceptable address. Matches the column, and RFC 5321's own limit.</summary>
private const int MaxEmailLength = 320;
/// <summary>
/// How long an invitation stays claimable.
/// </summary>
/// <remarks>
/// Fourteen days, and finite for a reason rather than as a default. An invitation that never
/// expired would be a standing offer against an address, and addresses are reassigned — a
/// company address handed to the next person to hold the job would let them into a team the
/// person who left was invited to. Fourteen days is long enough to survive a holiday and short
/// enough that a forgotten invitation lapses rather than waiting.
/// </remarks>
private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
/// <remarks>
/// Every member may read this, as with the members list and for the same reason: whoever is about
/// to be handed a vault key needs to see who else is on their way into the team. Accepted and
/// revoked rows are returned too, so the screen can show that an invitation was taken up rather
/// than having it silently vanish and read as never sent.
/// </remarks>
internal async Task<IReadOnlyList<TeamInvitationSummary>> ListAsync(
Guid teamId,
CancellationToken cancellationToken)
{
var invitations = await database.TeamInvitations
.Where(i => i.TeamId == teamId)
.OrderByDescending(i => i.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var now = clock.GetUtcNow();
return [.. invitations.Select(invitation => Describe(invitation, now))];
}
/// <summary>Invites an address to a team.</summary>
/// <remarks>
/// <para>
/// <b>An address that already has an account here is accepted rather than refused.</b> The obvious
/// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle
/// for which addresses have accounts, answerable by anybody willing to create a team first. It
/// would also be answering a question the caller did not ask: they want that person in the team,
/// and whether the account exists yet only changes how soon it happens. An existing account picks
/// the invitation up on its next request, within the hour.
/// </para>
/// <para>
/// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and
/// address returns the existing invitation rather than a second one. A different address under an
/// id already in use is refused rather than reinterpreted.
/// </para>
/// </remarks>
internal async Task<TeamInvitationSummary> CreateAsync(
UserAccount actor,
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var email = RequireEmail(request.Email);
var role = RequireInvitableRole(request.Role);
if (request.InvitationId == Guid.Empty)
{
throw new TeamInvitationInvalidException(
"An invitation id is required. Generate a UUIDv7 on the client.");
}
var now = clock.GetUtcNow();
var existing = await database.TeamInvitations
.SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return ResolveExisting(existing, teamId, email, now);
}
await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false);
var invitation = new TeamInvitation
{
Id = request.InvitationId,
TeamId = teamId,
Email = email,
Role = role,
InvitedByUserId = actor.Id,
CreatedAtUtc = now,
ExpiresAtUtc = now + Lifetime,
};
database.TeamInvitations.Add(invitation);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The partial unique index on (team, email) among live invitations. Reported as its own
// sentence because it is the one failure the caller could not see coming from their own
// input — somebody else may have invited the same person a minute earlier.
throw new TeamInvitationInvalidException(
"There is already an invitation to that address for this team. Withdraw it first if "
+ "you want to invite them at a different role.");
}
TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id);
return Describe(invitation, now);
}
/// <remarks>
/// A retry is the same id against the same team and address. Anything else under an id already in
/// use is refused rather than reinterpreted: returning a differently-addressed invitation would
/// tell a client its invite went to somebody it did not.
/// </remarks>
private static TeamInvitationSummary ResolveExisting(
TeamInvitation existing,
Guid teamId,
string email,
DateTimeOffset now)
{
var isRetry = existing.TeamId == teamId
&& string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase);
return isRetry
? Describe(existing, now)
: throw new TeamInvitationInvalidException(
"That invitation id is already in use. Generate a new UUIDv7 and retry.");
}
private static TeamRole RequireInvitableRole(TeamMemberRole role)
{
var domain = ToDomain(role);
return domain is TeamRole.Unspecified or TeamRole.Owner
? throw new TeamInvitationInvalidException(
"Invite somebody as a viewer, member or admin. Ownership is sole and is handed over "
+ "deliberately, never conferred by an address signing in.")
: domain;
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
/// <returns>Whether there was a live invitation to withdraw.</returns>
/// <remarks>
/// An invitation that has already been claimed is <em>not</em> withdrawable, and answering false
/// rather than unpicking it is the honest outcome: it is a membership now, and removing a member
/// is a different operation with different consequences — it revokes their vault key grants and
/// flags every team vault for rekey.
/// </remarks>
internal async Task<bool> RevokeAsync(
UserAccount actor,
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken)
{
var invitation = await database.TeamInvitations
.SingleOrDefaultAsync(
i => i.Id == invitationId
&& i.TeamId == teamId
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (invitation is null)
{
return false;
}
invitation.RevokedAtUtc = clock.GetUtcNow();
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id);
return true;
}
/// <inheritdoc />
public async Task<int> ClaimAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
if (string.IsNullOrWhiteSpace(email))
{
return 0;
}
var now = clock.GetUtcNow();
var pending = await database.TeamInvitations
.Where(i => i.Email == email
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null
&& i.ExpiresAtUtc > now)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (pending.Count == 0)
{
return 0;
}
if (!emailVerified)
{
// Logged rather than silent, and this is the only signal an operator gets that their
// provider is not sending the claim. Without it, invitations would simply never work and
// there would be nothing anywhere saying why.
TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id);
return 0;
}
return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false);
}
/// <summary>Turns each claimable invitation into an active membership.</summary>
private async Task<int> ApplyAsync(
UserAccount user,
List<TeamInvitation> pending,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var teamIds = pending.Select(i => i.TeamId).ToArray();
// Archived teams are excluded here as well as at archive time. An invitation issued moments
// before an archive can still be in flight, and joining a team nobody can see is worse than
// an invitation that quietly lapses.
var liveTeamIds = await database.Teams
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
.Select(t => t.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var live = liveTeamIds.ToHashSet();
var memberships = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId) && m.UserId == user.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var claimed = 0;
foreach (var invitation in pending.Where(i => live.Contains(i.TeamId)))
{
invitation.AcceptedAtUtc = now;
invitation.AcceptedByUserId = user.Id;
if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now))
{
claimed++;
TeamLog.InvitationClaimed(
logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role);
}
}
return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false);
}
/// <summary>Adds or reactivates the membership an invitation asks for.</summary>
/// <returns>Whether the membership changed. False means they were already an active member.</returns>
private bool Join(
UserAccount user,
TeamInvitation invitation,
TeamMembership? membership,
DateTimeOffset now)
{
if (membership is null)
{
database.TeamMemberships.Add(new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = invitation.TeamId,
UserId = user.Id,
Role = invitation.Role,
Status = MembershipStatus.Active,
InvitedByUserId = invitation.InvitedByUserId,
JoinedAtUtc = now,
CreatedAtUtc = now,
});
return true;
}
if (membership.Status == MembershipStatus.Active)
{
// Already in the team — the invitation is satisfied rather than applied. It must not
// change a role somebody set deliberately in the meantime, which is what re-applying an
// invitation issued weeks ago would silently do.
return false;
}
// Removed earlier and invited again. The row is reactivated rather than duplicated, exactly
// as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one
// membership. Their revoked key grants are not restored — those were wrapped to a generation
// the vault has since been flagged to leave behind.
membership.Role = invitation.Role;
membership.Status = MembershipStatus.Active;
membership.JoinedAtUtc = now;
return true;
}
/// <remarks>
/// Its own SaveChanges, never folded into the caller's. <c>CurrentUserContext.ProvisionAsync</c>
/// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that
/// call would put violations from this table inside a filter written for exactly one race, and
/// its rethrow would stop being correct.
/// </remarks>
private async Task<int> SaveClaimAsync(int claimed, CancellationToken cancellationToken)
{
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// Two of this account's requests claiming at once. One wins; the other finds nothing
// left to do on the next sweep. Swallowed rather than surfaced because this runs inside
// the authorization middleware, where a throw is a 500 on a request that was otherwise
// fine — and because the outcome the caller wanted has happened either way.
foreach (var entry in database.ChangeTracker.Entries<TeamMembership>().ToList())
{
entry.State = EntityState.Detached;
}
return 0;
}
return claimed;
}
/// <remarks>
/// Refused only for an account that is <em>already in this team</em> — a fact about a team the
/// caller can see, so naming it leaks nothing. Whether an address has an account at all is
/// deliberately not answered here; see <see cref="CreateAsync"/>.
/// </remarks>
private async Task RefuseIfAlreadyAMemberAsync(
Guid teamId,
string email,
CancellationToken cancellationToken)
{
var isMember = await database.TeamMemberships
.Where(m => m.TeamId == teamId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.Join(
database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null),
m => m.UserId,
u => u.Id,
(m, u) => m.Id)
.AnyAsync(cancellationToken)
.ConfigureAwait(false);
if (isMember)
{
throw new TeamInvitationInvalidException(
"That address already belongs to a member of this team. Change their role instead.");
}
}
/// <summary>Derives what has become of an invitation from its timestamps.</summary>
/// <remarks>
/// Computed rather than stored, which is why <see cref="TeamInvitationState"/> has no domain twin.
/// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an
/// invitation that read Pending because nothing had run yet would be a lie the interface repeats.
/// </remarks>
private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now)
{
var state = invitation switch
{
{ AcceptedAtUtc: not null } => TeamInvitationState.Accepted,
{ RevokedAtUtc: not null } => TeamInvitationState.Revoked,
_ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired,
_ => TeamInvitationState.Pending,
};
return new TeamInvitationSummary(
invitation.Id,
invitation.Email,
ToContract(invitation.Role),
state,
invitation.InvitedByUserId,
invitation.CreatedAtUtc,
invitation.ExpiresAtUtc,
invitation.AcceptedAtUtc);
}
/// <remarks>
/// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign
/// with something either side, no spaces, and inside the length the column holds — and nothing
/// more. A stricter address grammar here would reject addresses that a real identity provider
/// will happily assert, and the only thing that ultimately decides whether an address is that
/// person's is the provider marking it verified.
/// </remarks>
private static string RequireEmail(string? value)
{
var email = (value ?? string.Empty).Trim();
var at = email.IndexOf('@', StringComparison.Ordinal);
var acceptable = email.Length is > 2 and <= MaxEmailLength
&& at > 0
&& at == email.LastIndexOf('@')
&& at < email.Length - 1
&& !email.Any(char.IsWhiteSpace);
return acceptable
? email
: throw new TeamInvitationInvalidException(
"That does not look like an email address. Invite the address they sign in with.");
}
private static TeamRole ToDomain(TeamMemberRole role) => role switch
{
TeamMemberRole.Viewer => TeamRole.Viewer,
TeamMemberRole.Member => TeamRole.Member,
TeamMemberRole.Admin => TeamRole.Admin,
TeamMemberRole.Owner => TeamRole.Owner,
_ => TeamRole.Unspecified,
};
private static TeamMemberRole ToContract(TeamRole role) => role switch
{
TeamRole.Viewer => TeamMemberRole.Viewer,
TeamRole.Member => TeamMemberRole.Member,
TeamRole.Admin => TeamMemberRole.Admin,
TeamRole.Owner => TeamMemberRole.Owner,
_ => TeamMemberRole.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}
-45
View File
@@ -134,49 +134,4 @@ internal static partial class TeamLog
+ "The former owner is now an admin.")]
internal static partial void OwnershipTransferred(
ILogger logger, Guid teamId, Guid formerOwnerId, Guid newOwnerId);
/// <remarks>
/// The invitation id, never the address. TeamLog's rule is ids and outcomes only, and an email is
/// exactly the kind of personal detail a log aggregator would then keep for its whole retention.
/// </remarks>
[LoggerMessage(
EventId = 2111,
Level = LogLevel.Information,
Message = "Issued invitation {InvitationId} to team {TeamId} as {Role}, by {ActorId}.")]
internal static partial void InvitationIssued(
ILogger logger, Guid invitationId, Guid teamId, Domain.TeamRole role, Guid actorId);
[LoggerMessage(
EventId = 2112,
Level = LogLevel.Information,
Message = "Revoked invitation {InvitationId} to team {TeamId}, by {ActorId}.")]
internal static partial void InvitationRevoked(
ILogger logger, Guid invitationId, Guid teamId, Guid actorId);
[LoggerMessage(
EventId = 2113,
Level = LogLevel.Information,
Message = "User {UserId} claimed invitation {InvitationId} and joined team {TeamId} as {Role}.")]
internal static partial void InvitationClaimed(
ILogger logger, Guid userId, Guid invitationId, Guid teamId, Domain.TeamRole role);
/// <remarks>
/// <para>
/// Warning, and the one log line an operator will need when invitations appear not to work at all.
/// A provider that does not assert <c>email_verified</c> leaves every invitation pending for ever
/// with nothing else to show for it, and this is the only place that difference is visible.
/// </para>
/// <para>
/// It names the count and the account, never the address — the address is the thing being refused
/// as untrustworthy, and writing it to a log would be keeping a claim the server just rejected.
/// </para>
/// </remarks>
[LoggerMessage(
EventId = 2114,
Level = LogLevel.Warning,
Message = "Left {InvitationCount} invitation(s) unclaimed for user {UserId}: the access token "
+ "does not assert that their email address is verified. Check the identity provider "
+ "sends the email_verified claim.")]
internal static partial void InvitationNotClaimedUnverified(
ILogger logger, int invitationCount, Guid userId);
}
+5 -18
View File
@@ -277,20 +277,6 @@ internal sealed class TeamService(
membership.DeletedAtUtc = now;
}
// Pending invitations go too. An invitation that outlived its team would become a
// membership of something nobody can see, on a sign-in weeks later.
var invitations = await database.TeamInvitations
.Where(i => i.TeamId == team.Id
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var invitation in invitations)
{
invitation.RevokedAtUtc = now;
}
team.DeletedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
@@ -577,8 +563,9 @@ internal sealed class TeamService(
/// Both misses are specific, and neither is a new oracle. An id confirms nothing the caller did not
/// already know from the lookup that produced it. An address is answered only for an admin or owner
/// of the team the add names — checked by the endpoint before this runs — and is the same fact the
/// member list would show them a moment later. It carries its own code so the caller can invite the
/// address instead of reporting a failure at somebody who simply is not here yet.
/// member list would show them a moment later. It carries its own code so the caller can say what
/// has to happen next — that person signing in here once — rather than reporting a failure at
/// somebody who simply is not here yet.
/// </para>
/// </remarks>
private async Task<UserAccount> ResolveTargetAsync(
@@ -614,8 +601,8 @@ internal sealed class TeamService(
.ConfigureAwait(false)
?? throw new NoSuchAccountException(
"No account here uses that address yet. Invite it instead — they join when they "
+ "first sign in.");
"No account here uses that address yet. Ask them to sign in to this server once, "
+ "which is what creates the account, and then add them.");
}
/// <remarks>
+3 -10
View File
@@ -37,21 +37,14 @@ builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<DirectoryService>();
builder.Services.AddScoped<KeyLogService>();
builder.Services.AddScoped<TeamService>();
builder.Services.AddScoped<TeamInvitationService>();
// Registered twice on purpose, resolving to the same scoped instance: the endpoints take the
// concrete service, and CurrentUserContext takes only the claim it needs, so the sign-in path does
// not gain a reference to the whole of a feature it calls one method on.
builder.Services.AddScoped<ITeamInvitationClaim>(
provider => provider.GetRequiredService<TeamInvitationService>());
builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
// resolving to the same instance, for the reason the invitation claim above is: the endpoint needs the
// whole hub — admit, remove, count — while the write paths that announce a change need only the two
// methods that announce one, and should not gain a reference to connection management to get them.
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
// write paths that announce a change need only the two methods that announce one, and should not gain
// a reference to connection management to get them.
builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>(
provider => provider.GetRequiredService<VaultEventHub>());
+7 -20
View File
@@ -62,28 +62,15 @@ public sealed class OidcOptions
public string EmailClaim { get; set; } = "email";
/// <summary>Claim type holding the user's display name.</summary>
public string NameClaim { get; set; } = "name";
/// <summary>
/// Claim type asserting that the provider has verified the user's email.
/// </summary>
/// <remarks>
/// <para>
/// Read for exactly one purpose: deciding whether a pending team invitation addressed to that
/// email may be claimed. Nothing else in this server trusts the email claim for anything, and
/// <see cref="AllowEmailLinking"/> records why — a token from any configured provider carrying a
/// victim's address must not confer access to anything of theirs. An invitation is access, so it
/// needs the same bar.
/// </para>
/// <para>
/// <b>Absence is a refusal, not a default.</b> A provider that does not send this claim leaves
/// every invitation pending for ever, which is visible on the teams screen and diagnosable in the
/// log. There is deliberately no option to trust an unverified address instead: a flag that exists
/// is a flag somebody turns on for the afternoon their provider is misconfigured, and this is the
/// one it must not be possible to turn on.
/// </para>
/// The email and the display name are read for display and for the directory's address lookup, and
/// for nothing that decides access. There was an <c>EmailVerifiedClaim</c> beside these, read by
/// exactly one feature — claiming a team invitation addressed to that address — and it went with
/// the invitations. Nothing in this server now trusts the email claim to grant anything, which is
/// the property <see cref="AllowEmailLinking"/> spends its whole doc comment defending; re-adding a
/// setting here would be the first step back towards a token's address being a way in.
/// </remarks>
public string EmailVerifiedClaim { get; set; } = "email_verified";
public string NameClaim { get; set; } = "name";
}
/// <summary>Schema management.</summary>
@@ -54,9 +54,6 @@ internal static class EndpointRegistration
typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint),
typeof(ListTeamInvitationsEndpoint),
typeof(CreateTeamInvitationEndpoint),
typeof(RevokeTeamInvitationEndpoint),
typeof(CreateTeamVaultEndpoint),
typeof(RenameVaultEndpoint),
typeof(DeleteVaultEndpoint),
@@ -129,8 +129,8 @@
<!--
Vaults, which the v2 design has no row for — it is a shipped screen the design had no slot for
rather than a drawn one with nothing behind it. It is on the phone because an invitation is
claimed by signing in, and somebody being invited is at least as likely to be holding a phone.
rather than a drawn one with nothing behind it. It is on the phone because a vault arrives
without being asked for, and somebody it arrives for is at least as likely to be holding a phone.
◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and
two heads giving one destination two marks is how a user learns the wrong one.
@@ -10,14 +10,11 @@
VAULTS, under MORE — and the one screen behind that hub the v2 phone design never drew.
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because an
invitation is claimed by *signing in*, and the person being invited is at least as likely to be
holding a phone as sitting at a desktop — a vault the server has just put somebody into, visible only
on a head they may never have installed, is a membership they cannot see.
That argument is also why the invited list is drawn here and not treated as an administrator's detail:
the people on it are the ones who cannot yet see the vault, and the row says out loud that no mail was
sent.
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because a vault
arrives without being asked for — somebody wraps its key to you from their machine — and the person it
arrives for is at least as likely to be holding a phone as sitting at a desktop. A vault the server has
just put somebody into, visible only on a head they may never have installed, is a membership they
cannot see.
── IT LISTED TEAMS UNTIL THE SCREEN STOPPED BEING ABOUT THEM. ───────────────────────────────────────
The rows are vaults now, and the members under one are the people that vault is shared with. Nothing
@@ -39,20 +36,20 @@
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
machine that already holds the key, and this phone is one — so a vaults screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE, WITHDRAW
KEY and WITHDRAW INVITATION are the other half of that, and each of them acts on the first press: the
view model's armed-confirmation state covers handing a vault over and nothing else. The desktop guards
would leave the product's central claim undemonstrated on the head most people carry. REMOVE and
WITHDRAW KEY are the other half of that, and both act on the first press: the view model's
armed-confirmation state covers handing a vault over and nothing else. The desktop guards
them with a tooltip instead, which is a control a touch screen has no way to show. An irreversible
revocation under a thumb with its explanation missing is the wrong trade, so all three stay on the
revocation under a thumb with its explanation missing is the wrong trade, so both stay on the
desktop — where the sentence beside them is visible. Handing a vault over is not drawn either, for a
plainer reason: it decides who controls the vault, which is not a thing to do while walking. Nor is
renaming, which is a keyboard on a screen that is otherwise all reading.
**ADD is not drawn either**, and it is the operation this screen least needs. It is an address typed
into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding somebody
did *not* do — and since invitations arrived the ordinary way into a vault is one the server claims at
sign-in, which is what put this screen on the phone at all. Making a vault is here, because it is one
field and because it is what a person carrying a phone can usefully start.
did *not* do — five controls for the one act on this screen that a colleague at a desktop is already
doing. Making a vault is here, because it is one field and because it is what a person carrying a
phone can usefully start.
**The key-holder list is not drawn.** It is a third list, and what the phone can answer about a vault
is the more useful half of the same question and is on the vault's own row: whether *this* machine can
@@ -61,7 +58,7 @@
**↻ and `+` both, because this screen has more reason to re-read than any other.** Who is in a vault is
not cached — it is read from the server on arrival and again at the end of every command — so the one
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
from a colleague's desktop, or a vault they have this moment been invited into. On the desktop the
from a colleague's desktop, or a vault they have this moment been added to. On the desktop the
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Vaults),
which would set Screen to the value it already holds, raise nothing and reload nothing.
@@ -246,53 +243,6 @@
<TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a vault is what lets the server hand somebody its rows. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
<!-- ============ ◆ who has been asked and has not arrived ============ -->
<!--
The section this screen exists for, and the one the desktop had nothing to draw until
invitations were built. Read-only here: withdrawing one is a control that acts on the first
press, which is the line drawn at the top of this file.
So these are cards rather than the flat rows above them, and the shape is the difference: a
row that fills when you touch it is one of several you are choosing between, and there is
nothing to choose here. An ItemsControl rather than a ListBox for the same reason — a list
with a selection nothing reads would be a control offering something it cannot do.
Gated on the view model's own count rather than left to stand over an empty list, because a
vault with nobody outstanding is the ordinary case and a permanent empty heading would make
it look like a section that had failed to load.
The waiting row carries the whole mechanism in its own sentence — no mail was sent, and they
join when they first sign in here. That is the sentence somebody has to read, because every
other product's version of this word means an email is on its way.
-->
<StackPanel IsVisible="{Binding HasInvitations}">
<TextBlock Classes="section" Text="INVITED" Margin="18,18,18,4" />
<ItemsControl ItemsSource="{Binding Invitations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultInvitationRowViewModel">
<Border Classes="card" Margin="12,3">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding !IsPending}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding IsPending}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</StackPanel>
@@ -123,30 +123,6 @@ public interface ITeamApi
/// </returns>
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken);
/// <summary>Invites an email address to a team.</summary>
Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws an invitation that has not been taken up.
/// </summary>
/// <returns>
/// Whether there was a live invitation to withdraw. False covers one that was never there and one
/// already claimed — a claimed invitation is a membership now, and removing a member is a different
/// operation with different consequences.
/// </returns>
Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken);
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
@@ -506,39 +482,6 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest),
DodoSshJsonContext.Default.TeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(
CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"),
cancellationToken);
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
+10 -43
View File
@@ -12,7 +12,7 @@
The left column used to list teams; a team owned vaults, and sharing meant creating a team, then a
vault in it, then wrapping a key. Two of those three steps were about a concept nobody came here for.
So the rows are vaults now: naming one makes the membership list that carries it, and everything on
the right — members, invitations, key holders — is that vault's. The server still authorises against
the right — members, key holders — is that vault's. The server still authorises against
a team, because that is what VaultAccessService resolves; what went is the requirement that a person
know it exists. The one case where it is still visible is a membership list carrying several vaults,
which this screen cannot make and will not hide: see SharedMembershipWarning.
@@ -25,11 +25,12 @@
than a checkbox on the member row.
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
this product) and avatars (no picture is stored anywhere). Nothing is sent for an invitation — there
is no outbound mail path and no token, so an invitation is a standing instruction that the next
account signing in with that address joins, and there is consequently nothing to resend. Last-active
is recorded at most once per account per hour, so it is drawn coarsely. Nor is there a way to delete a
vault: the server has no such call, and the screen says so rather than offering a button that refuses.
this product) and avatars (no picture is stored anywhere). There is no INVITED list either, and that
one is a decision rather than a gap — an address is not a way into a vault, so only an account that
already exists can be added and there is nothing pending to draw. See the ADD box below, which says
what to do about somebody who has not signed in here yet. Last-active is recorded at most once per
account per hour, so it is drawn coarsely. Nor is there a way to delete a vault: the server has no
such call, and the screen says so rather than offering a button that refuses.
-->
<Grid ColumnDefinitions="268,*">
@@ -261,11 +262,11 @@
</StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
Margin="0,0,6,0" />
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
Text="{Binding NewMemberEmail}" Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Adds the account with this address, or invites the address if there is no account here yet. Nothing is sent either way — tell them yourself." />
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so — ask them to sign in to this server once, which is what creates the account, and then add them." />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
@@ -291,40 +292,6 @@
Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
</StackPanel>
<!--
Invitations, drawn only when there are any. An empty INVITED heading on every vault would be
a permanent reminder of a feature most people never use.
-->
<StackPanel Spacing="8" IsVisible="{Binding HasInvitations}">
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<TextBlock Classes="label" Text="INVITED" />
<ListBox ItemsSource="{Binding Invitations}" SelectedItem="{Binding SelectedInvitation}"
Background="Transparent" BorderThickness="0" MaxHeight="160">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultInvitationRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Email}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Classes="danger" Content="WITHDRAW INVITATION" HorizontalAlignment="Left"
Command="{Binding RevokeInvitationCommand}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanAdministerSelected}"
ToolTip.Tip="Signing in with that address will no longer put them in this vault. An invitation already taken up is a membership — remove the member instead." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Who holds the key -->
@@ -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));
@@ -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>
+1 -1
View File
@@ -66,7 +66,7 @@ public sealed record Argon2Profile
public static Argon2Profile PassphraseHigh { get; } = new(512, 4);
/// <summary>
/// Profile for 128-bit random secrets — recovery codes and invite secrets.
/// Profile for 128-bit random secrets — recovery codes.
/// </summary>
/// <remarks>
/// KDF hardening is nearly irrelevant for a full-entropy random secret; this is
+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; }
}
@@ -62,47 +62,6 @@ public sealed class TeamMembershipConfiguration : IEntityTypeConfiguration<TeamM
}
}
/// <summary>Maps <see cref="TeamInvitation"/>.</summary>
public sealed class TeamInvitationConfiguration : IEntityTypeConfiguration<TeamInvitation>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TeamInvitation> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("team_invitation");
builder.HasKey(i => i.Id);
builder.Property(i => i.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
// citext, matching user_account.email: the claim at sign-in compares an address the identity
// provider chose the casing of against one a person typed, and lower() on both sides of that
// is a rule somebody eventually forgets on one side.
builder.Property(i => i.Email).HasColumnType("citext").HasMaxLength(320).IsRequired();
builder.Property(i => i.Role).HasConversion<int>();
builder.HasOne(i => i.Team)
.WithMany(t => t.Invitations)
.HasForeignKey(i => i.TeamId)
.OnDelete(DeleteBehavior.Cascade);
// One live invitation per address per team. Filtered on the two tombstones rather than on a
// deletion marker, as vault_key_grant is: an accepted or withdrawn invitation is kept, and
// re-inviting an address whose invitation lapsed has to be possible.
//
// Expiry is deliberately not in this predicate. A partial index predicate must be IMMUTABLE,
// so now() cannot appear in one; an expired invitation therefore still holds the slot, and
// the service treats replacing one as a revoke-and-reissue rather than a second insert.
builder.HasIndex(i => new { i.TeamId, i.Email })
.IsUnique()
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
// The claim at sign-in knows the address and nothing else — it is looking for every team that
// invited this person, across all of them — so the address is the hot direction here.
builder.HasIndex(i => i.Email);
}
}
/// <summary>Maps <see cref="Vault"/>.</summary>
public sealed class VaultConfiguration : IEntityTypeConfiguration<Vault>
{
@@ -45,9 +45,6 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>Team memberships.</summary>
public DbSet<TeamMembership> TeamMemberships => Set<TeamMembership>();
/// <summary>Invitations to addresses with no account here yet.</summary>
public DbSet<TeamInvitation> TeamInvitations => Set<TeamInvitation>();
/// <summary>Vaults.</summary>
public DbSet<Vault> Vaults => Set<Vault>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <summary>
/// Removes the invitation table. Membership is now only ever granted to an account that exists.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pending invitations are dropped rather than converted, and that is the decision rather than
/// the omission.</b> Converting one would mean creating a membership because an address matched —
/// which is the exact property this change exists to remove, and the same one
/// <c>OidcOptions.AllowEmailLinking</c> refuses one door along. In practice almost nothing is lost:
/// an invitation to an address that already had an account here was claimed within the hour by the
/// sign-in sweep, so what is left in this table is offers to people who never arrived, and there
/// was never an account to make a membership out of.
/// </para>
/// <para>
/// The Down is a faithful rebuild of the empty table and nothing else. It cannot bring the rows
/// back, and a migration that pretended otherwise would be worse than one that says so.
/// </para>
/// </remarks>
public partial class DropTeamInvitation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "team_invitation",
schema: "dodo");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "team_invitation",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
team_id = table.Column<Guid>(type: "uuid", nullable: false),
accepted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
accepted_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: false),
expires_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
invited_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
revoked_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
role = table.Column<int>(type: "integer", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_team_invitation", x => x.id);
table.ForeignKey(
name: "fk_team_invitation_team_team_id",
column: x => x.team_id,
principalSchema: "dodo",
principalTable: "team",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_team_invitation_email",
schema: "dodo",
table: "team_invitation",
column: "email");
migrationBuilder.CreateIndex(
name: "ix_team_invitation_team_id_email",
schema: "dodo",
table: "team_invitation",
columns: new[] { "team_id", "email" },
unique: true,
filter: "accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
}
}
}
@@ -316,70 +316,6 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("team", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset?>("AcceptedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("accepted_at_utc");
b.Property<Guid?>("AcceptedByUserId")
.HasColumnType("uuid")
.HasColumnName("accepted_by_user_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("citext")
.HasColumnName("email");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at_utc");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid")
.HasColumnName("invited_by_user_id");
b.Property<DateTimeOffset?>("RevokedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at_utc");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("TeamId")
.HasColumnType("uuid")
.HasColumnName("team_id");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_team_invitation");
b.HasIndex("Email")
.HasDatabaseName("ix_team_invitation_email");
b.HasIndex("TeamId", "Email")
.IsUnique()
.HasDatabaseName("ix_team_invitation_team_id_email")
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
b.ToTable("team_invitation", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{
b.Property<Guid>("Id")
@@ -1644,18 +1580,6 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.HasOne("DodoSSH.Domain.Team", "Team")
.WithMany("Invitations")
.HasForeignKey("TeamId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_team_invitation_team_team_id");
b.Navigation("Team");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{
b.HasOne("DodoSSH.Domain.Team", "Team")
@@ -1858,8 +1782,6 @@ namespace DodoSSH.Infrastructure.Migrations
modelBuilder.Entity("DodoSSH.Domain.Team", b =>
{
b.Navigation("Invitations");
b.Navigation("Memberships");
});