Files
DodoSSH/src/DodoSSH.Api/Features/Teams/TeamService.cs
T
jaap-janandClaude Opus 5 a43286ece8 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>
2026-08-03 14:31:43 +02:00

979 lines
39 KiB
C#

using System.Globalization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>The result of a team access check.</summary>
/// <param name="Team">The team, when the caller is an active member.</param>
/// <param name="Role">The caller's role.</param>
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
{
/// <summary>Whether the caller is in this team at all.</summary>
public bool Granted => Team is not null;
/// <summary>
/// Whether the caller may manage members and vaults.
/// </summary>
/// <remarks>
/// The team-level counterpart of <c>PermissionFlags.Admin</c>, and deliberately not derived from
/// it: those flags describe a vault, and adding a member is not an operation on any vault.
/// </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);
}
/// <summary>
/// Teams and their membership.
/// </summary>
/// <remarks>
/// <para>
/// <b>Membership is authorization; a key grant is access.</b> Everything in this class moves rows
/// that decide what the <em>server</em> will serve. None of it can make a vault readable, because
/// making a vault readable means wrapping its key to somebody's public key and only a client holding
/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so:
/// add them here, then share the vault key from a machine that has one. Collapsing the two would
/// require the server to hold a key, which is the one thing this design is built to avoid.
/// </para>
/// <para>
/// The reverse direction is the honest half of the same split. Removing a member revokes their
/// grants and flags every team vault for rekey, and that blocks <em>future</em> reads only. Anything
/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See
/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would
/// imply more than it delivers.
/// </para>
/// </remarks>
internal sealed class TeamService(
DodoDbContext database,
TimeProvider clock,
ILogger<TeamService> logger)
{
/// <summary>Longest acceptable slug. Matches the column.</summary>
private const int MaxSlugLength = 128;
/// <summary>Longest acceptable display name. Matches the column.</summary>
private const int MaxNameLength = 256;
/// <summary>Longest acceptable description. Matches the column.</summary>
private const int MaxDescriptionLength = 2048;
/// <summary>Creates a team, with the caller as its owner.</summary>
/// <remarks>
/// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was
/// lost can be re-sent verbatim and returns the same team rather than creating a second one under
/// a name the user meant to type once. A different body under the same id is a client that has
/// lost track of its own state and is refused rather than silently reinterpreted.
/// </remarks>
internal async Task<TeamSummary> CreateAsync(
UserAccount user,
CreateTeamRequest request,
CancellationToken cancellationToken)
{
var name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
var slug = RequireSlug(request.Slug);
var description = OptionalText(request.Description, MaxDescriptionLength);
if (request.TeamId == Guid.Empty)
{
throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client.");
}
var existing = await database.Teams
.SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return await ResolveExistingAsync(user, existing, name, slug, cancellationToken)
.ConfigureAwait(false);
}
var team = AddTeamWithOwner(user, request.TeamId, name, slug, description);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The partial unique index on slug. Reported as its own code because it is the one
// failure the caller could not have foreseen from their own input.
throw new TeamSlugTakenException(
$"The slug '{slug}' is already in use. Choose another.");
}
TeamLog.TeamCreated(logger, team.Id, user.Id);
return new TeamSummary(
team.Id, team.Name, team.Slug, team.Description,
TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc);
}
/// <summary>
/// Adds the team row and the creator's owner membership.
/// </summary>
/// <remarks>
/// The two together, never one: a team with no members has nobody who can add any, and the row
/// would have to be found and fixed by hand.
/// </remarks>
private Team AddTeamWithOwner(
UserAccount user,
Guid teamId,
string name,
string slug,
string? description)
{
var now = clock.GetUtcNow();
var team = new Team
{
Id = teamId,
Name = name,
Slug = slug,
Description = description,
CreatedByUserId = user.Id,
CreatedAtUtc = now,
};
database.Teams.Add(team);
database.TeamMemberships.Add(new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = team.Id,
UserId = user.Id,
Role = TeamRole.Owner,
Status = MembershipStatus.Active,
JoinedAtUtc = now,
CreatedAtUtc = now,
});
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,
CancellationToken cancellationToken)
{
var memberships = await database.TeamMemberships
.Where(m => m.UserId == user.Id
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (memberships.Count == 0)
{
return [];
}
var teamIds = memberships.Select(m => m.TeamId).ToArray();
var teams = await database.Teams
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
.OrderBy(t => t.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var memberCounts = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId)
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.GroupBy(m => m.TeamId)
.Select(g => new { TeamId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
.ConfigureAwait(false);
var vaultCounts = await database.Vaults
.Where(v => v.OwnerKind == VaultOwnerKind.Team
&& v.TeamId != null
&& teamIds.Contains(v.TeamId.Value)
&& v.DeletedAtUtc == null)
.GroupBy(v => v.TeamId!.Value)
.Select(g => new { TeamId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
.ConfigureAwait(false);
return
[
.. teams.Select(team => new TeamSummary(
team.Id,
team.Name,
team.Slug,
team.Description,
ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role),
memberCounts.GetValueOrDefault(team.Id),
vaultCounts.GetValueOrDefault(team.Id),
team.CreatedAtUtc)),
];
}
/// <summary>
/// Lists a team's members.
/// </summary>
/// <remarks>
/// Available to every member, not only to admins. Whoever is about to be handed a vault key needs
/// to know who else already holds one, and a directory that only administrators can read makes
/// the sharing graph less visible to the people it is about than it is to the operator — who can
/// read it straight out of the database either way.
/// </remarks>
internal async Task<IReadOnlyList<TeamMemberSummary>> ListMembersAsync(
Guid teamId,
CancellationToken cancellationToken)
{
var memberships = await database.TeamMemberships
.Where(m => m.TeamId == teamId && m.DeletedAtUtc == null)
.Include(m => m.User)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (memberships.Count == 0)
{
return [];
}
var userIds = memberships.Select(m => m.UserId).ToArray();
var enrolled = await database.UserKeys
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
.Select(k => k.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var enrolledIds = enrolled.ToHashSet();
return
[
.. memberships
.OrderByDescending(m => m.Role)
.ThenBy(m => m.CreatedAtUtc)
.Select(m => new TeamMemberSummary(
m.UserId,
m.User?.Email,
m.User?.DisplayName,
ToContract(m.Role),
ToContract(m.Status),
enrolledIds.Contains(m.UserId),
m.JoinedAtUtc,
m.User?.LastSeenAtUtc)),
];
}
/// <summary>Adds a member, or reactivates one who was removed.</summary>
/// <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 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,
/// which is what keeps historic audit entries resolvable to one membership. It does <em>not</em>
/// restore their revoked key grants: those were wrapped to a generation the vault has since been
/// flagged to leave behind, and a member holding Share has to wrap the key afresh.
/// </para>
/// </remarks>
internal async Task<TeamMemberSummary> AddMemberAsync(
UserAccount actor,
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken)
{
var role = ToDomain(request.Role);
if (role is TeamRole.Unspecified or TeamRole.Owner)
{
throw new TeamInvalidException(
"Add a member as viewer, member or admin. Ownership is sole and is not transferred "
+ "by adding somebody.");
}
var target = await database.Users
.SingleOrDefaultAsync(
u => u.Id == request.UserId && u.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false)
// Safe to be specific: the caller supplied this id from a directory lookup they just
// made, so it confirms nothing they did not already know.
?? throw new TeamInvalidException(
"No such account on this server. A member has to sign in here once before they can "
+ "be added — that is what creates the account and publishes the key a vault would "
+ "be shared with.");
var now = clock.GetUtcNow();
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId && m.UserId == target.Id && m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (membership is null)
{
membership = new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = teamId,
UserId = target.Id,
InvitedByUserId = actor.Id,
CreatedAtUtc = now,
};
database.TeamMemberships.Add(membership);
}
else if (membership.Status == MembershipStatus.Active)
{
throw new TeamInvalidException(
"That account is already a member of this team. Change their role instead.");
}
membership.Role = role;
membership.Status = MembershipStatus.Active;
membership.JoinedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id);
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
}
/// <remarks>
/// Enrollment is looked up rather than inferred, because it is the one field on a member row that
/// is about them and not about the membership: somebody can be added on Monday and set their
/// vault up on Tuesday, and the interface has to stop offering to share with them in between.
/// </remarks>
private async Task<TeamMemberSummary> DescribeAsync(
UserAccount user,
TeamMembership membership,
CancellationToken cancellationToken)
{
var isEnrolled = await database.UserKeys
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
.ConfigureAwait(false);
return new TeamMemberSummary(
user.Id,
user.Email,
user.DisplayName,
ToContract(membership.Role),
ToContract(membership.Status),
isEnrolled,
membership.JoinedAtUtc,
user.LastSeenAtUtc);
}
/// <summary>Changes a member's role.</summary>
internal async Task<TeamMemberSummary> ChangeRoleAsync(
UserAccount actor,
Guid teamId,
Guid memberId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken)
{
var role = ToDomain(request.Role);
if (role is TeamRole.Unspecified or TeamRole.Owner)
{
throw new TeamInvalidException(
"A member may be made a viewer, a member or an admin. Ownership is sole and is not "
+ "granted this way.");
}
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
// 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 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;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id);
// The account cannot be missing — a membership has a foreign key to it — but the query is
// written to tolerate it rather than to assert, because a null here would become an
// exception on a change that has already been committed.
var user = await database.Users
.SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken)
.ConfigureAwait(false);
return user is null
? new TeamMemberSummary(
memberId, null, null, ToContract(role), ToContract(membership.Status), false,
membership.JoinedAtUtc)
: await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Removes a member, revoking every vault key grant they hold from this team.
/// </summary>
/// <remarks>
/// <para>
/// One transaction, because the two halves are not separable: a membership revoked without its
/// grants leaves a departed member holding a key the server will happily keep serving, and grants
/// revoked without the membership leaves an active member whose vaults have silently stopped
/// opening.
/// </para>
/// <para>
/// Every affected vault is flagged <c>RekeyRequired</c> rather than rekeyed. A rekey re-wraps
/// every item's data key under a new vault key and can only be performed by a client that holds
/// the current one; the server can record that one is owed and nothing more. That is M5's key
/// rotation, and until it lands the flag is what the interface reads to say so out loud.
/// </para>
/// </remarks>
internal async Task RemoveMemberAsync(
UserAccount actor,
Guid teamId,
Guid memberId,
CancellationToken cancellationToken)
{
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"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();
var strategy = database.Database.CreateExecutionStrategy();
var revoked = await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
membership.Status = MembershipStatus.Revoked;
membership.DeletedAtUtc = now;
var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken)
.ConfigureAwait(false);
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return count;
}).ConfigureAwait(false);
TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);
}
/// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
private async Task<int> RevokeTeamGrantsAsync(
Guid teamId,
Guid memberId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var vaults = await database.Vaults
.Where(v => v.TeamId == teamId
&& v.OwnerKind == VaultOwnerKind.Team
&& v.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (vaults.Count == 0)
{
return 0;
}
var vaultIds = vaults.Select(v => v.Id).ToArray();
var grants = await database.VaultKeyGrants
.Where(g => vaultIds.Contains(g.VaultId)
&& g.RecipientUserId == memberId
&& g.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var grant in grants)
{
grant.State = GrantState.Revoked;
grant.RevokedAtUtc = now;
}
// Flagged whether or not this member held a grant. Somebody who was a member without a key
// still saw the vault's existence, its item count and its plaintext columns, and the vault's
// key is what a rekey would change — so "they never had a grant" is not a reason to leave the
// flag clear.
foreach (var vault in vaults)
{
vault.RekeyRequired = true;
vault.RekeyReason = RekeyReason.MemberRemoved;
vault.UpdatedAtUtc = now;
}
return grants.Count;
}
/// <summary>Reads the caller's own membership, for authorization checks.</summary>
internal Task<TeamMembership?> FindActiveMembershipAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) =>
database.TeamMemberships.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == userId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken);
/// <summary>
/// Resolves what the caller may do with a team.
/// </summary>
/// <remarks>
/// Answers <see cref="TeamAccess.Denied"/> identically for a team that does not exist and one the
/// caller is not in, for the reason <c>VaultAccessService</c> gives: a distinct "exists but
/// forbidden" is an oracle for other tenants' team ids.
/// </remarks>
internal async Task<TeamAccess> ResolveAsync(
Guid userId,
Guid teamId,
CancellationToken cancellationToken)
{
var team = await database.Teams
.SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (team is null)
{
return TeamAccess.Denied;
}
var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken)
.ConfigureAwait(false);
return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role);
}
private async Task<TeamMembership> RequireMembershipAsync(
Guid teamId,
Guid memberId,
CancellationToken cancellationToken)
{
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == memberId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return membership
?? 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
/// would be an existence oracle, and returning a differently-named one would tell a client its
/// rename succeeded when nothing changed.
/// </remarks>
private async Task<TeamSummary> ResolveExistingAsync(
UserAccount user,
Team existing,
string name,
string slug,
CancellationToken cancellationToken)
{
var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken)
.ConfigureAwait(false);
var isRetry = membership?.Role == TeamRole.Owner
&& existing.DeletedAtUtc == null
&& string.Equals(existing.Name, name, StringComparison.Ordinal)
&& string.Equals(existing.Slug, slug, StringComparison.Ordinal);
if (!isRetry)
{
throw new TeamInvalidException(
"That team id is already in use. Generate a new UUIDv7 and retry.");
}
var memberCount = await database.TeamMemberships
.CountAsync(
m => m.TeamId == existing.Id
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
var vaultCount = await database.Vaults
.CountAsync(
v => v.TeamId == existing.Id && v.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return new TeamSummary(
existing.Id, existing.Name, existing.Slug, existing.Description,
TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc);
}
/// <summary>
/// Validates a slug.
/// </summary>
/// <remarks>
/// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
/// purpose: the column is <c>citext</c>, so a slug differing only in case is the same slug, and a
/// value that renders differently from how it compares is how two teams end up looking distinct
/// in a list and colliding on insert.
/// </remarks>
private static string RequireSlug(string? value)
{
var slug = (value ?? string.Empty).Trim();
if (slug.Length is 0 or > MaxSlugLength)
{
throw new TeamInvalidException(
$"A slug of 1 to {MaxSlugLength} characters is required.");
}
var previousWasHyphen = false;
for (var index = 0; index < slug.Length; index++)
{
var character = slug[index];
var isHyphen = character == '-';
var acceptable = (character is >= 'a' and <= 'z')
|| (character is >= '0' and <= '9')
|| isHyphen;
if (!acceptable
|| (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1)))
{
throw new TeamInvalidException(
"A slug is lowercase letters, digits and single hyphens, and cannot start or end "
+ "with a hyphen.");
}
previousWasHyphen = isHyphen;
}
return slug;
}
private static string RequireText(string? value, string field, int maxLength)
{
var text = (value ?? string.Empty).Trim();
if (text.Length == 0 || text.Length > maxLength)
{
throw new TeamInvalidException(
string.Create(
CultureInfo.InvariantCulture,
$"{field} is required, and at most {maxLength} characters."));
}
return text;
}
private static string? OptionalText(string? value, int maxLength)
{
var text = value?.Trim();
if (string.IsNullOrEmpty(text))
{
return null;
}
if (text.Length > maxLength)
{
throw new TeamInvalidException(
string.Create(
CultureInfo.InvariantCulture,
$"A description is at most {maxLength} characters."));
}
return text;
}
/// <remarks>
/// A plain cast, which is why <c>TeamMemberRole</c> pins the same numeric values as
/// <see cref="TeamRole"/> and a test asserts it. An unknown value becomes
/// <see cref="TeamRole.Unspecified"/> rather than a silent cast to a role nobody defined, so a
/// newer client's role is refused instead of resolving to whatever bit pattern it happens to be.
/// </remarks>
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 TeamMemberStatus ToContract(MembershipStatus status) => status switch
{
MembershipStatus.Invited => TeamMemberStatus.Invited,
MembershipStatus.Active => TeamMemberStatus.Active,
MembershipStatus.Revoked => TeamMemberStatus.Revoked,
_ => TeamMemberStatus.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}