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); }