Let a team change hands, and be joined by somebody with no account yet

M3 built teams and stopped short of the two operations that decide who
controls one. Both were written down as refusals rather than omissions:
ADR 0009 listed ownership transfer under "deliberately not built", and
design-import-gaps said an invitation needed "a token with a lifetime and an
outbound mail path". One of those reasons had expired and the other never
applied — an invitation does not need a token if it is not a thing anybody
presents.

Handing a team over is one write. The member you name becomes owner and you
become an admin, in a single transaction, because ownership is sole: promoting
first leaves the team owned twice, demoting first leaves it owned by nobody,
and there is nobody left with the authority to finish a transfer that stopped
in the middle. That is also why it is not two calls to the role endpoint, which
refuses Owner outright. The outgoing owner is demoted rather than removed —
removing them would revoke their vault key grants and flag every team vault for
rekey, which is a far larger act than the one asked for, and somebody handing
over a team is usually staying in it. It unblocks the thing that was impossible
before: an owner can now leave, by handing the team on first.

An invitation is a standing instruction rather than a message. 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 signing in with that address joins
this team at this role, and telling them to sign in is the caller's job over a
channel this server does not carry. A link nobody can deliver would be worse
than none. It lives in its own table rather than becoming a membership with
MembershipStatus.Invited, and that member stays unwritten for the reason it
always was: team_membership.user_id is not nullable and carries a foreign key,
so somebody who has never signed in has nothing for that row to point at.
Widening it would make the unique index on (team, user) meaningless, because
PostgreSQL counts every NULL as distinct.

Verification is the security boundary, and nothing in this server read it
before. A claim requires the access token to assert email_verified. An
invitation decides what the server will serve, so one claimable by anybody able
to obtain a token carrying somebody else's address is a way into a team — which
is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it
would have been reintroduced by the back door. There is deliberately no setting
that relaxes it: a flag that exists is one somebody turns on for the afternoon
their provider is misconfigured. Absence is refused rather than trusted, and
logged, because a provider that never sends the claim otherwise leaves every
invitation pending with nothing anywhere saying why.

Claiming happens at just-in-time provisioning and again on an hourly sweep. The
sweep is what makes it recoverable rather than one-shot — an invitation issued
between an account being created and that person next signing in would
otherwise be stranded for ever — and it shares its rate with the last-seen
write because both are housekeeping nobody is waiting on.

Archiving is refused while a team owns a vault, and that refusal is the end of
the road rather than a step on it. A team vault is readable because of
membership, so archiving one that still owned vaults would take them away from
everybody holding a key, including the caller, quietly and all at once. Nothing
in this product deletes a vault, so no order of operations gets past it today —
which is stated with a count of what is in the way, for the reason the SFTP
layer refuses a recursive delete: a refusal is visible and a quiet removal is
not. It is owner-only, as handing over is; renaming is not, because a rename is
visible to everybody and reversible by anybody who can do it. The slug is not
renameable at all: it is unique only among live teams, so a rename could take
one an archived team is still holding, and that team could then never be
restored.

LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is
refreshed on ordinary authenticated requests, at most once per account per
hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency
token, so a read-then-write on the hot path would start losing races between
one user's own overlapping requests. An hour is the granularity the question is
actually asked at, and the interface draws it to the day rather than the minute
so it does not read as a precision that is not there. The remarks in Contracts
and in the view model that argued at length for the column's absence are
rewritten rather than extended; both had become false.

Two endpoints already existed and nothing called them. ChangeTeamMemberRole and
ListVaultGrants have been reachable since M3. The role picker refuses Owner
itself rather than letting the server do it, since the interface already knew
the rule; the key-holder list sits under the vault rather than beside the
member, because a grant is per vault and a count on a member row would imply
per-item sharing, which is M5. It lists withdrawn and stale grants and says
which they are — a list that dropped them would show a departed colleague as
merely absent rather than as somebody whose key was taken away — and staleness
is decided by comparing generations, since a grant can be Active and still open
nothing.

ADD MEMBER stopped being a dead end. An address the directory did not know used
to end at a sentence telling the user their colleague had to sign in first. It
invites them instead, from the same button, because which of the two applies is
a fact about the server's account table rather than about what the user is
doing; which one happened is reported afterwards, because that decides what
they do next. An address that merely has an account is invited rather than
refused: refusing would have made the endpoint an oracle for which addresses
have accounts here, answerable by anybody willing to create a team first.

The phone has a TEAMS screen, behind MORE, and it is the reverse of every other
row in design-import-gaps: a shipped screen the design had no slot for. It is
there because an invitation is claimed by signing in, so somebody told they are
now in a team is at least as likely to be holding a phone — and a membership
visible only on a head they never installed is one they cannot see. It draws
SHARE KEY and nothing that takes something away: wrapping a key is the one act
on that screen a server cannot perform at all, and the desktop guards its
revocations with a tooltip, which is a control a touch screen cannot show.

Two defects were found by an adversarial pass and both were green against the
whole suite at the time. The owner-only check on archiving and handing over had
been weakened to the admin check while their messages and comments still said
owner — and since nothing behind the archive endpoint re-checks it, an admin
the owner had promoted could have archived the team out from under them. And
the rename endpoint built its response with a hardcoded Owner role, so an admin
who renamed a team was handed a summary claiming they owned it, and a client
trusting that instead of re-listing would have offered them the two owner-only
buttons the server then refuses.

The new table gets its constraints tested rather than merely migrated: live
uniqueness per (team, address), the citext proof that an address typed by a
person matches one cased by a provider, and reissue after both revocation and
acceptance. The teams screen gets its first entries in the layout suite, at the
minimum window with every list populated and with each of the two states that
cover half of it — it had none, and it just grew four sections and a second
line in the member row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:31:43 +02:00
co-authored by Claude Opus 5
parent 16e0051e89
commit a43286ece8
43 changed files with 6974 additions and 147 deletions
+242 -9
View File
@@ -24,6 +24,17 @@ internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
/// </remarks>
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
/// <summary>
/// Whether the caller owns this team.
/// </summary>
/// <remarks>
/// Distinct from <see cref="CanAdminister"/>, and the distinction is load-bearing: an admin may
/// manage members and vaults, but archiving a team and handing it to somebody else are the two
/// things that decide whether the team continues to exist and who controls it. Gating those on
/// <see cref="CanAdminister"/> would let anybody the owner promoted take the team from them.
/// </remarks>
public bool IsOwner => Role is TeamRole.Owner;
/// <summary>Denied access.</summary>
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
}
@@ -156,6 +167,200 @@ internal sealed class TeamService(
return team;
}
/// <summary>Renames a team, or changes its description.</summary>
/// <remarks>
/// The slug is not touched and cannot be. It is unique only among live teams, so a rename could
/// take a slug an archived team still holds, and that archived team could then never be restored
/// — a rename that quietly forecloses somebody else's recovery is worse than one the product
/// simply does not offer. There is also nowhere to record that this happened: <c>team</c> has no
/// updated-at column, so nothing can show "edited" and the log line is the only trace.
/// </remarks>
/// <param name="actor">Who is renaming it.</param>
/// <param name="access">
/// The caller's resolved access. The <em>role</em> is taken from here rather than assumed, because
/// an admin may rename a team and telling them the response says <see cref="TeamRole.Owner"/> would
/// hand a client a summary claiming rights it does not have — and this is the one write on a team
/// that both an admin and an owner can perform.
/// </param>
/// <param name="request">The new name and description.</param>
/// <param name="cancellationToken">Cancellation.</param>
internal async Task<TeamSummary> UpdateAsync(
UserAccount actor,
TeamAccess access,
UpdateTeamRequest request,
CancellationToken cancellationToken)
{
var team = access.Team
?? throw new TeamInvalidException("That team is not there.");
ArgumentNullException.ThrowIfNull(request);
team.Name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
team.Description = OptionalText(request.Description, MaxDescriptionLength);
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.TeamUpdated(logger, team.Id, actor.Id);
var memberCount = await CountMembersAsync(team.Id, cancellationToken).ConfigureAwait(false);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
return new TeamSummary(
team.Id, team.Name, team.Slug, team.Description,
ToContract(access.Role), memberCount, vaultCount, team.CreatedAtUtc);
}
/// <summary>
/// Archives a team, provided it owns no vaults.
/// </summary>
/// <remarks>
/// <para>
/// <b>The vault check is the whole of this operation's safety and it refuses rather than
/// cascades.</b> Archiving a team hides it from every member's list at once, and a team vault
/// resolves through membership — so archiving one that still owned vaults would take those vaults
/// away from people who hold keys to them, silently, including the caller. Nothing in this product
/// deletes a vault, so there is no sequence of calls that turns this refusal into a success today.
/// That is stated plainly rather than worked around, for the reason the SFTP layer refuses a
/// recursive delete: a refusal is visible and a quiet removal is not.
/// </para>
/// <para>
/// Memberships are archived with the team, in one transaction, because a live membership pointing
/// at an archived team is a row every membership query has to remember to exclude twice. The slug
/// is freed by the same write — the unique index is filtered on <c>deleted_at_utc IS NULL</c> — so
/// a team can be recreated under the archived one's slug, and restoring the archived one would
/// then collide. Only an operator can restore it, and this is the thing they have to look at
/// first.
/// </para>
/// </remarks>
internal async Task ArchiveAsync(
UserAccount actor,
Team team,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(team);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
{
throw new TeamNotEmptyException(
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. There is no way to delete a vault in this product yet, so a team with vaults cannot be archived."));
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var archived = await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
var memberships = await database.TeamMemberships
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var membership in memberships)
{
membership.Status = MembershipStatus.Revoked;
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);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return memberships.Count;
}).ConfigureAwait(false);
TeamLog.TeamArchived(logger, team.Id, actor.Id, archived);
}
/// <summary>
/// Hands ownership to another active member, demoting the outgoing owner to admin.
/// </summary>
/// <remarks>
/// <para>
/// One transaction, because ownership is sole and the two writes are not separable: promoting
/// first leaves the team owned twice, demoting first leaves it owned by nobody, and a failure
/// between them leaves whichever of those the ordering chose. That is why this is not two calls
/// to <see cref="ChangeRoleAsync"/>, which refuses <see cref="TeamRole.Owner"/> outright.
/// </para>
/// <para>
/// The recipient must already be an active member. Adding somebody and handing them the team in
/// one step would let an id supplied once take it, and the reason
/// <see cref="AddMemberAsync"/> refuses the owner role is the same one.
/// </para>
/// <para>
/// The outgoing owner is demoted rather than removed. Removing them would revoke their vault key
/// grants and flag every team vault for rekey — a far larger act than the one asked for, and
/// somebody handing over a team is usually staying in it.
/// </para>
/// </remarks>
internal async Task TransferOwnershipAsync(
UserAccount actor,
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.UserId == actor.Id)
{
throw new TeamInvalidException("You already own this team.");
}
var outgoing = await RequireMembershipAsync(teamId, actor.Id, cancellationToken)
.ConfigureAwait(false);
// Belt and braces: the endpoint already refused anybody who is not the owner. Checking again
// here keeps the invariant with the code that enforces it rather than one layer away.
if (outgoing.Role != TeamRole.Owner)
{
throw new LastTeamOwnerException("Only this team's owner can hand it over.");
}
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
incoming.Role = TeamRole.Owner;
outgoing.Role = TeamRole.Admin;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
TeamLog.OwnershipTransferred(logger, teamId, actor.Id, request.UserId);
}
/// <summary>Lists the teams the caller is an active member of.</summary>
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
UserAccount user,
@@ -260,7 +465,8 @@ internal sealed class TeamService(
ToContract(m.Role),
ToContract(m.Status),
enrolledIds.Contains(m.UserId),
m.JoinedAtUtc)),
m.JoinedAtUtc,
m.User?.LastSeenAtUtc)),
];
}
@@ -268,8 +474,9 @@ internal sealed class TeamService(
/// <remarks>
/// <para>
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
/// somebody else is a transfer rather than an addition — a different operation with a different
/// confirmation, and not one M3 offers.
/// somebody else is a transfer rather than an addition — a different operation, with its own
/// endpoint, which demotes the outgoing owner in the same transaction. Adding somebody straight
/// to owner would hand a team to an id typed once.
/// </para>
/// <para>
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
@@ -365,7 +572,8 @@ internal sealed class TeamService(
ToContract(membership.Role),
ToContract(membership.Status),
isEnrolled,
membership.JoinedAtUtc);
membership.JoinedAtUtc,
user.LastSeenAtUtc);
}
/// <summary>Changes a member's role.</summary>
@@ -388,12 +596,15 @@ internal sealed class TeamService(
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
// Demoting the owner is what would leave the team ownerless, and there is no transfer to
// do it through yet. Refused with the code a client can act on rather than a bare 400.
// Demoting the owner here would leave the team ownerless, because this operation cannot
// appoint a replacement in the same breath. Transferring can, and does both at once — so
// the refusal names it rather than saying the thing is impossible.
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be demoted, because nothing can appoint a replacement yet.");
"This team's owner cannot be demoted on its own. Transfer ownership to another "
+ "member instead: that hands the team over and makes the outgoing owner an admin, "
+ "in one step, so the team is never left with nobody who can manage it.");
}
membership.Role = role;
@@ -445,8 +656,9 @@ internal sealed class TeamService(
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be removed. Ownership transfer is not implemented, so "
+ "removing them would leave the team with nobody who can manage it.");
"This team's owner cannot be removed while they own it, because that would leave the "
+ "team with nobody who can manage it. Transfer ownership to another member first — "
+ "the outgoing owner becomes an admin and can then be removed like anybody else.");
}
var now = clock.GetUtcNow();
@@ -580,6 +792,27 @@ internal sealed class TeamService(
?? throw new TeamInvalidException("That account is not an active member of this team.");
}
/// <summary>Counts a team's active members.</summary>
private Task<int> CountMembersAsync(Guid teamId, CancellationToken cancellationToken) =>
database.TeamMemberships.CountAsync(
m => m.TeamId == teamId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken);
/// <summary>Counts the vaults a team owns.</summary>
/// <remarks>
/// Filtered on <c>OwnerKind</c> as well as on the id, matching <see cref="ListAsync"/>. A vault
/// carrying a team id it does not belong to would otherwise be counted here and not there, and
/// this count is what decides whether a team may be archived.
/// </remarks>
private Task<int> CountVaultsAsync(Guid teamId, CancellationToken cancellationToken) =>
database.Vaults.CountAsync(
v => v.TeamId == teamId
&& v.OwnerKind == VaultOwnerKind.Team
&& v.DeletedAtUtc == null,
cancellationToken);
/// <remarks>
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
/// else under an id that is already taken is refused: silently returning somebody else's team