using DodoSSH.Api.Authorization; using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; using Npgsql; namespace DodoSSH.Api.Features.Teams; /// /// Invitations to addresses that have no account here yet, and the sign-in path that claims them. /// /// /// /// An invitation is a standing instruction, not a message and not a token. This server has no /// outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row /// says "the next account to sign in with this address joins this team as this role", and telling /// them to sign in is the caller's job over a channel this server does not carry. That is a smaller /// feature than the design drew, and it is the whole of what can be built honestly without a mail /// path — a link nobody can deliver would be worse than none. /// /// /// Verification is the security boundary, and it is the only one. Membership is authorization /// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the /// identity provider has not marked verified would let anybody who can get a token asserting somebody /// else's address walk into their team — which is precisely the attack /// OidcOptions.AllowEmailLinking exists to refuse. So an unverified address claims nothing, /// there is no setting that relaxes it, and the refusal is logged rather than silent. /// /// /// What an invitation still cannot do is make anything readable. It creates a membership, and a /// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine /// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves /// the first half of it earlier. /// /// internal sealed class TeamInvitationService( DodoDbContext database, TimeProvider clock, ILogger logger) : ITeamInvitationClaim { /// Longest acceptable address. Matches the column, and RFC 5321's own limit. private const int MaxEmailLength = 320; /// /// How long an invitation stays claimable. /// /// /// Fourteen days, and finite for a reason rather than as a default. An invitation that never /// expired would be a standing offer against an address, and addresses are reassigned — a /// company address handed to the next person to hold the job would let them into a team the /// person who left was invited to. Fourteen days is long enough to survive a holiday and short /// enough that a forgotten invitation lapses rather than waiting. /// private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14); /// Lists a team's invitations, including the ones already dealt with. /// /// Every member may read this, as with the members list and for the same reason: whoever is about /// to be handed a vault key needs to see who else is on their way into the team. Accepted and /// revoked rows are returned too, so the screen can show that an invitation was taken up rather /// than having it silently vanish and read as never sent. /// internal async Task> ListAsync( Guid teamId, CancellationToken cancellationToken) { var invitations = await database.TeamInvitations .Where(i => i.TeamId == teamId) .OrderByDescending(i => i.CreatedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); var now = clock.GetUtcNow(); return [.. invitations.Select(invitation => Describe(invitation, now))]; } /// Invites an address to a team. /// /// /// An address that already has an account here is accepted rather than refused. The obvious /// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle /// for which addresses have accounts, answerable by anybody willing to create a team first. It /// would also be answering a question the caller did not ask: they want that person in the team, /// and whether the account exists yet only changes how soon it happens. An existing account picks /// the invitation up on its next request, within the hour. /// /// /// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and /// address returns the existing invitation rather than a second one. A different address under an /// id already in use is refused rather than reinterpreted. /// /// internal async Task CreateAsync( UserAccount actor, Guid teamId, CreateTeamInvitationRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var email = RequireEmail(request.Email); var role = RequireInvitableRole(request.Role); if (request.InvitationId == Guid.Empty) { throw new TeamInvitationInvalidException( "An invitation id is required. Generate a UUIDv7 on the client."); } var now = clock.GetUtcNow(); var existing = await database.TeamInvitations .SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken) .ConfigureAwait(false); if (existing is not null) { return ResolveExisting(existing, teamId, email, now); } await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false); var invitation = new TeamInvitation { Id = request.InvitationId, TeamId = teamId, Email = email, Role = role, InvitedByUserId = actor.Id, CreatedAtUtc = now, ExpiresAtUtc = now + Lifetime, }; database.TeamInvitations.Add(invitation); try { await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } catch (DbUpdateException exception) when (IsUniqueViolation(exception)) { // The partial unique index on (team, email) among live invitations. Reported as its own // sentence because it is the one failure the caller could not see coming from their own // input — somebody else may have invited the same person a minute earlier. throw new TeamInvitationInvalidException( "There is already an invitation to that address for this team. Withdraw it first if " + "you want to invite them at a different role."); } TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id); return Describe(invitation, now); } /// /// A retry is the same id against the same team and address. Anything else under an id already in /// use is refused rather than reinterpreted: returning a differently-addressed invitation would /// tell a client its invite went to somebody it did not. /// private static TeamInvitationSummary ResolveExisting( TeamInvitation existing, Guid teamId, string email, DateTimeOffset now) { var isRetry = existing.TeamId == teamId && string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase); return isRetry ? Describe(existing, now) : throw new TeamInvitationInvalidException( "That invitation id is already in use. Generate a new UUIDv7 and retry."); } private static TeamRole RequireInvitableRole(TeamMemberRole role) { var domain = ToDomain(role); return domain is TeamRole.Unspecified or TeamRole.Owner ? throw new TeamInvitationInvalidException( "Invite somebody as a viewer, member or admin. Ownership is sole and is handed over " + "deliberately, never conferred by an address signing in.") : domain; } /// Withdraws an invitation that has not been taken up. /// Whether there was a live invitation to withdraw. /// /// An invitation that has already been claimed is not withdrawable, and answering false /// rather than unpicking it is the honest outcome: it is a membership now, and removing a member /// is a different operation with different consequences — it revokes their vault key grants and /// flags every team vault for rekey. /// internal async Task RevokeAsync( UserAccount actor, Guid teamId, Guid invitationId, CancellationToken cancellationToken) { var invitation = await database.TeamInvitations .SingleOrDefaultAsync( i => i.Id == invitationId && i.TeamId == teamId && i.AcceptedAtUtc == null && i.RevokedAtUtc == null, cancellationToken) .ConfigureAwait(false); if (invitation is null) { return false; } invitation.RevokedAtUtc = clock.GetUtcNow(); await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id); return true; } /// public async Task ClaimAsync( UserAccount user, string? email, bool emailVerified, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(user); if (string.IsNullOrWhiteSpace(email)) { return 0; } var now = clock.GetUtcNow(); var pending = await database.TeamInvitations .Where(i => i.Email == email && i.AcceptedAtUtc == null && i.RevokedAtUtc == null && i.ExpiresAtUtc > now) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (pending.Count == 0) { return 0; } if (!emailVerified) { // Logged rather than silent, and this is the only signal an operator gets that their // provider is not sending the claim. Without it, invitations would simply never work and // there would be nothing anywhere saying why. TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id); return 0; } return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false); } /// Turns each claimable invitation into an active membership. private async Task ApplyAsync( UserAccount user, List pending, DateTimeOffset now, CancellationToken cancellationToken) { var teamIds = pending.Select(i => i.TeamId).ToArray(); // Archived teams are excluded here as well as at archive time. An invitation issued moments // before an archive can still be in flight, and joining a team nobody can see is worse than // an invitation that quietly lapses. var liveTeamIds = await database.Teams .Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null) .Select(t => t.Id) .ToListAsync(cancellationToken) .ConfigureAwait(false); var live = liveTeamIds.ToHashSet(); var memberships = await database.TeamMemberships .Where(m => teamIds.Contains(m.TeamId) && m.UserId == user.Id && m.DeletedAtUtc == null) .ToListAsync(cancellationToken) .ConfigureAwait(false); var claimed = 0; foreach (var invitation in pending.Where(i => live.Contains(i.TeamId))) { invitation.AcceptedAtUtc = now; invitation.AcceptedByUserId = user.Id; if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now)) { claimed++; TeamLog.InvitationClaimed( logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role); } } return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false); } /// Adds or reactivates the membership an invitation asks for. /// Whether the membership changed. False means they were already an active member. private bool Join( UserAccount user, TeamInvitation invitation, TeamMembership? membership, DateTimeOffset now) { if (membership is null) { database.TeamMemberships.Add(new TeamMembership { Id = Guid.CreateVersion7(), TeamId = invitation.TeamId, UserId = user.Id, Role = invitation.Role, Status = MembershipStatus.Active, InvitedByUserId = invitation.InvitedByUserId, JoinedAtUtc = now, CreatedAtUtc = now, }); return true; } if (membership.Status == MembershipStatus.Active) { // Already in the team — the invitation is satisfied rather than applied. It must not // change a role somebody set deliberately in the meantime, which is what re-applying an // invitation issued weeks ago would silently do. return false; } // Removed earlier and invited again. The row is reactivated rather than duplicated, exactly // as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one // membership. Their revoked key grants are not restored — those were wrapped to a generation // the vault has since been flagged to leave behind. membership.Role = invitation.Role; membership.Status = MembershipStatus.Active; membership.JoinedAtUtc = now; return true; } /// /// Its own SaveChanges, never folded into the caller's. CurrentUserContext.ProvisionAsync /// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that /// call would put violations from this table inside a filter written for exactly one race, and /// its rethrow would stop being correct. /// private async Task SaveClaimAsync(int claimed, CancellationToken cancellationToken) { try { await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } catch (DbUpdateException exception) when (IsUniqueViolation(exception)) { // Two of this account's requests claiming at once. One wins; the other finds nothing // left to do on the next sweep. Swallowed rather than surfaced because this runs inside // the authorization middleware, where a throw is a 500 on a request that was otherwise // fine — and because the outcome the caller wanted has happened either way. foreach (var entry in database.ChangeTracker.Entries().ToList()) { entry.State = EntityState.Detached; } return 0; } return claimed; } /// /// Refused only for an account that is already in this team — a fact about a team the /// caller can see, so naming it leaks nothing. Whether an address has an account at all is /// deliberately not answered here; see . /// private async Task RefuseIfAlreadyAMemberAsync( Guid teamId, string email, CancellationToken cancellationToken) { var isMember = await database.TeamMemberships .Where(m => m.TeamId == teamId && m.Status == MembershipStatus.Active && m.DeletedAtUtc == null) .Join( database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null), m => m.UserId, u => u.Id, (m, u) => m.Id) .AnyAsync(cancellationToken) .ConfigureAwait(false); if (isMember) { throw new TeamInvitationInvalidException( "That address already belongs to a member of this team. Change their role instead."); } } /// Derives what has become of an invitation from its timestamps. /// /// Computed rather than stored, which is why has no domain twin. /// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an /// invitation that read Pending because nothing had run yet would be a lie the interface repeats. /// private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now) { var state = invitation switch { { AcceptedAtUtc: not null } => TeamInvitationState.Accepted, { RevokedAtUtc: not null } => TeamInvitationState.Revoked, _ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired, _ => TeamInvitationState.Pending, }; return new TeamInvitationSummary( invitation.Id, invitation.Email, ToContract(invitation.Role), state, invitation.InvitedByUserId, invitation.CreatedAtUtc, invitation.ExpiresAtUtc, invitation.AcceptedAtUtc); } /// /// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign /// with something either side, no spaces, and inside the length the column holds — and nothing /// more. A stricter address grammar here would reject addresses that a real identity provider /// will happily assert, and the only thing that ultimately decides whether an address is that /// person's is the provider marking it verified. /// private static string RequireEmail(string? value) { var email = (value ?? string.Empty).Trim(); var at = email.IndexOf('@', StringComparison.Ordinal); var acceptable = email.Length is > 2 and <= MaxEmailLength && at > 0 && at == email.LastIndexOf('@') && at < email.Length - 1 && !email.Any(char.IsWhiteSpace); return acceptable ? email : throw new TeamInvitationInvalidException( "That does not look like an email address. Invite the address they sign in with."); } private static TeamRole ToDomain(TeamMemberRole role) => role switch { TeamMemberRole.Viewer => TeamRole.Viewer, TeamMemberRole.Member => TeamRole.Member, TeamMemberRole.Admin => TeamRole.Admin, TeamMemberRole.Owner => TeamRole.Owner, _ => TeamRole.Unspecified, }; private static TeamMemberRole ToContract(TeamRole role) => role switch { TeamRole.Viewer => TeamMemberRole.Viewer, TeamRole.Member => TeamMemberRole.Member, TeamRole.Admin => TeamMemberRole.Admin, TeamRole.Owner => TeamMemberRole.Owner, _ => TeamMemberRole.Unspecified, }; private static bool IsUniqueViolation(DbUpdateException exception) => string.Equals( (exception.InnerException as PostgresException)?.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal); }