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