using System.Globalization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// The result of a team access check.
/// The team, when the caller is an active member.
/// The caller's role.
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
{
/// Whether the caller is in this team at all.
public bool Granted => Team is not null;
///
/// Whether the caller may manage members and vaults.
///
///
/// The team-level counterpart of PermissionFlags.Admin, and deliberately not derived from
/// it: those flags describe a vault, and adding a member is not an operation on any vault.
///
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
///
/// Whether the caller owns this team.
///
///
/// Distinct from , 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
/// would let anybody the owner promoted take the team from them.
///
public bool IsOwner => Role is TeamRole.Owner;
/// Denied access.
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
}
///
/// Teams and their membership.
///
///
///
/// Membership is authorization; a key grant is access. Everything in this class moves rows
/// that decide what the server 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.
///
///
/// 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 future 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.
///
///
internal sealed class TeamService(
DodoDbContext database,
TimeProvider clock,
ILogger logger)
{
/// Longest acceptable slug. Matches the column.
private const int MaxSlugLength = 128;
/// Longest acceptable display name. Matches the column.
private const int MaxNameLength = 256;
/// Longest acceptable description. Matches the column.
private const int MaxDescriptionLength = 2048;
/// Creates a team, with the caller as its owner.
///
/// 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.
///
internal async Task 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);
}
///
/// Adds the team row and the creator's owner membership.
///
///
/// 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.
///
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;
}
/// Renames a team, or changes its description.
///
/// 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: team has no
/// updated-at column, so nothing can show "edited" and the log line is the only trace.
///
/// Who is renaming it.
///
/// The caller's resolved access. The role is taken from here rather than assumed, because
/// an admin may rename a team and telling them the response says 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.
///
/// The new name and description.
/// Cancellation.
internal async Task 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);
}
///
/// Archives a team, provided it owns no vaults.
///
///
///
/// The vault check is the whole of this operation's safety and it refuses rather than
/// cascades. 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.
///
///
/// 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 deleted_at_utc IS NULL — 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.
///
///
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);
}
///
/// Hands ownership to another active member, demoting the outgoing owner to admin.
///
///
///
/// 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 , which refuses outright.
///
///
/// 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
/// refuses the owner role is the same one.
///
///
/// 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.
///
///
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);
}
/// Lists the teams the caller is an active member of.
internal async Task> 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)),
];
}
///
/// Lists a team's members.
///
///
/// 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.
///
internal async Task> 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)),
];
}
/// Adds a member, or reactivates one who was removed.
///
///
/// The role may not be . 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.
///
///
/// 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 not
/// 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.
///
///
internal async Task 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);
}
///
/// 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.
///
private async Task 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);
}
/// Changes a member's role.
internal async Task 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);
}
///
/// Removes a member, revoking every vault key grant they hold from this team.
///
///
///
/// 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.
///
///
/// Every affected vault is flagged RekeyRequired 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.
///
///
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);
}
/// Revokes one user's grants on every vault a team owns, and flags each for rekey.
private async Task 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;
}
/// Reads the caller's own membership, for authorization checks.
internal Task 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);
///
/// Resolves what the caller may do with a team.
///
///
/// Answers identically for a team that does not exist and one the
/// caller is not in, for the reason VaultAccessService gives: a distinct "exists but
/// forbidden" is an oracle for other tenants' team ids.
///
internal async Task 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 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.");
}
/// Counts a team's active members.
private Task CountMembersAsync(Guid teamId, CancellationToken cancellationToken) =>
database.TeamMemberships.CountAsync(
m => m.TeamId == teamId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken);
/// Counts the vaults a team owns.
///
/// Filtered on OwnerKind as well as on the id, matching . 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.
///
private Task CountVaultsAsync(Guid teamId, CancellationToken cancellationToken) =>
database.Vaults.CountAsync(
v => v.TeamId == teamId
&& v.OwnerKind == VaultOwnerKind.Team
&& v.DeletedAtUtc == null,
cancellationToken);
///
/// 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.
///
private async Task 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);
}
///
/// Validates a slug.
///
///
/// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
/// purpose: the column is citext, 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.
///
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;
}
///
/// A plain cast, which is why TeamMemberRole pins the same numeric values as
/// and a test asserts it. An unknown value becomes
/// 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.
///
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);
}