Public Access
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.
It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.
So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.
Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.
Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.
Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
1038 lines
42 KiB
C#
1038 lines
42 KiB
C#
using System.Globalization;
|
|
using DodoSSH.Api.Features.Events;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Domain;
|
|
using DodoSSH.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Npgsql;
|
|
|
|
namespace DodoSSH.Api.Features.Teams;
|
|
|
|
/// <summary>The result of a team access check.</summary>
|
|
/// <param name="Team">The team, when the caller is an active member.</param>
|
|
/// <param name="Role">The caller's role.</param>
|
|
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
|
|
{
|
|
/// <summary>Whether the caller is in this team at all.</summary>
|
|
public bool Granted => Team is not null;
|
|
|
|
/// <summary>
|
|
/// Whether the caller may manage members and vaults.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The team-level counterpart of <c>PermissionFlags.Admin</c>, and deliberately not derived from
|
|
/// it: those flags describe a vault, and adding a member is not an operation on any vault.
|
|
/// </remarks>
|
|
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
|
|
|
|
/// <summary>
|
|
/// Whether the caller owns this team.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Distinct from <see cref="CanAdminister"/>, and the distinction is load-bearing: an admin may
|
|
/// manage members and vaults, but archiving a team and handing it to somebody else are the two
|
|
/// things that decide whether the team continues to exist and who controls it. Gating those on
|
|
/// <see cref="CanAdminister"/> would let anybody the owner promoted take the team from them.
|
|
/// </remarks>
|
|
public bool IsOwner => Role is TeamRole.Owner;
|
|
|
|
/// <summary>Denied access.</summary>
|
|
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Teams and their membership.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Membership is authorization; a key grant is access.</b> Everything in this class moves rows
|
|
/// that decide what the <em>server</em> will serve. None of it can make a vault readable, because
|
|
/// making a vault readable means wrapping its key to somebody's public key and only a client holding
|
|
/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so:
|
|
/// add them here, then share the vault key from a machine that has one. Collapsing the two would
|
|
/// require the server to hold a key, which is the one thing this design is built to avoid.
|
|
/// </para>
|
|
/// <para>
|
|
/// The reverse direction is the honest half of the same split. Removing a member revokes their
|
|
/// grants and flags every team vault for rekey, and that blocks <em>future</em> reads only. Anything
|
|
/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See
|
|
/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would
|
|
/// imply more than it delivers.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class TeamService(
|
|
DodoDbContext database,
|
|
TimeProvider clock,
|
|
IVaultEventPublisher events,
|
|
ILogger<TeamService> logger)
|
|
{
|
|
/// <summary>Longest acceptable slug. Matches the column.</summary>
|
|
private const int MaxSlugLength = 128;
|
|
|
|
/// <summary>Longest acceptable display name. Matches the column.</summary>
|
|
private const int MaxNameLength = 256;
|
|
|
|
/// <summary>Longest acceptable description. Matches the column.</summary>
|
|
private const int MaxDescriptionLength = 2048;
|
|
|
|
/// <summary>Creates a team, with the caller as its owner.</summary>
|
|
/// <remarks>
|
|
/// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was
|
|
/// lost can be re-sent verbatim and returns the same team rather than creating a second one under
|
|
/// a name the user meant to type once. A different body under the same id is a client that has
|
|
/// lost track of its own state and is refused rather than silently reinterpreted.
|
|
/// </remarks>
|
|
internal async Task<TeamSummary> CreateAsync(
|
|
UserAccount user,
|
|
CreateTeamRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
|
|
var slug = RequireSlug(request.Slug);
|
|
var description = OptionalText(request.Description, MaxDescriptionLength);
|
|
|
|
if (request.TeamId == Guid.Empty)
|
|
{
|
|
throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client.");
|
|
}
|
|
|
|
var existing = await database.Teams
|
|
.SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (existing is not null)
|
|
{
|
|
return await ResolveExistingAsync(user, existing, name, slug, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
var team = AddTeamWithOwner(user, request.TeamId, name, slug, description);
|
|
|
|
try
|
|
{
|
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
|
{
|
|
// The partial unique index on slug. Reported as its own code because it is the one
|
|
// failure the caller could not have foreseen from their own input.
|
|
throw new TeamSlugTakenException(
|
|
$"The slug '{slug}' is already in use. Choose another.");
|
|
}
|
|
|
|
TeamLog.TeamCreated(logger, team.Id, user.Id);
|
|
|
|
return new TeamSummary(
|
|
team.Id, team.Name, team.Slug, team.Description,
|
|
TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the team row and the creator's owner membership.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The two together, never one: a team with no members has nobody who can add any, and the row
|
|
/// would have to be found and fixed by hand.
|
|
/// </remarks>
|
|
private Team AddTeamWithOwner(
|
|
UserAccount user,
|
|
Guid teamId,
|
|
string name,
|
|
string slug,
|
|
string? description)
|
|
{
|
|
var now = clock.GetUtcNow();
|
|
|
|
var team = new Team
|
|
{
|
|
Id = teamId,
|
|
Name = name,
|
|
Slug = slug,
|
|
Description = description,
|
|
CreatedByUserId = user.Id,
|
|
CreatedAtUtc = now,
|
|
};
|
|
|
|
database.Teams.Add(team);
|
|
|
|
database.TeamMemberships.Add(new TeamMembership
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TeamId = team.Id,
|
|
UserId = user.Id,
|
|
Role = TeamRole.Owner,
|
|
Status = MembershipStatus.Active,
|
|
JoinedAtUtc = now,
|
|
CreatedAtUtc = now,
|
|
});
|
|
|
|
return team;
|
|
}
|
|
|
|
/// <summary>Renames a team, or changes its description.</summary>
|
|
/// <remarks>
|
|
/// The slug is not touched and cannot be. It is unique only among live teams, so a rename could
|
|
/// take a slug an archived team still holds, and that archived team could then never be restored
|
|
/// — a rename that quietly forecloses somebody else's recovery is worse than one the product
|
|
/// simply does not offer. There is also nowhere to record that this happened: <c>team</c> has no
|
|
/// updated-at column, so nothing can show "edited" and the log line is the only trace.
|
|
/// </remarks>
|
|
/// <param name="actor">Who is renaming it.</param>
|
|
/// <param name="access">
|
|
/// The caller's resolved access. The <em>role</em> is taken from here rather than assumed, because
|
|
/// an admin may rename a team and telling them the response says <see cref="TeamRole.Owner"/> would
|
|
/// hand a client a summary claiming rights it does not have — and this is the one write on a team
|
|
/// that both an admin and an owner can perform.
|
|
/// </param>
|
|
/// <param name="request">The new name and description.</param>
|
|
/// <param name="cancellationToken">Cancellation.</param>
|
|
internal async Task<TeamSummary> UpdateAsync(
|
|
UserAccount actor,
|
|
TeamAccess access,
|
|
UpdateTeamRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var team = access.Team
|
|
?? throw new TeamInvalidException("That team is not there.");
|
|
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
team.Name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
|
|
team.Description = OptionalText(request.Description, MaxDescriptionLength);
|
|
|
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
TeamLog.TeamUpdated(logger, team.Id, actor.Id);
|
|
|
|
var memberCount = await CountMembersAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
|
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
|
|
|
return new TeamSummary(
|
|
team.Id, team.Name, team.Slug, team.Description,
|
|
ToContract(access.Role), memberCount, vaultCount, team.CreatedAtUtc);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Archives a team, provided it owns no vaults.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The vault check is the whole of this operation's safety and it refuses rather than
|
|
/// cascades.</b> Archiving a team hides it from every member's list at once, and a team vault
|
|
/// resolves through membership — so archiving one that still owned vaults would take those vaults
|
|
/// away from people who hold keys to them, silently, including the caller. The way out is to delete
|
|
/// those vaults first — <c>VaultGrantService.DeleteVaultAsync</c>, which asks its own question and
|
|
/// withdraws every key — and then archive what is left. A refusal that names a route is worth more
|
|
/// than a cascade, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and
|
|
/// a quiet removal is not.
|
|
/// </para>
|
|
/// <para>
|
|
/// Deleting the <em>last</em> vault of a team made to carry it archives that team on the way past, so
|
|
/// the ordinary case never reaches this refusal at all. See <c>DeleteVaultEndpoint</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Memberships are archived with the team, in one transaction, because a live membership pointing
|
|
/// at an archived team is a row every membership query has to remember to exclude twice. The slug
|
|
/// is freed by the same write — the unique index is filtered on <c>deleted_at_utc IS NULL</c> — so
|
|
/// a team can be recreated under the archived one's slug, and restoring the archived one would
|
|
/// then collide. Only an operator can restore it, and this is the thing they have to look at
|
|
/// first.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task ArchiveAsync(
|
|
UserAccount actor,
|
|
Team team,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(team);
|
|
|
|
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (vaultCount > 0)
|
|
{
|
|
throw new TeamNotEmptyException(
|
|
string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
team.DeletedAtUtc = now;
|
|
|
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return memberships.Count;
|
|
}).ConfigureAwait(false);
|
|
|
|
TeamLog.TeamArchived(logger, team.Id, actor.Id, archived);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hands ownership to another active member, demoting the outgoing owner to admin.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One transaction, because ownership is sole and the two writes are not separable: promoting
|
|
/// first leaves the team owned twice, demoting first leaves it owned by nobody, and a failure
|
|
/// between them leaves whichever of those the ordering chose. That is why this is not two calls
|
|
/// to <see cref="ChangeRoleAsync"/>, which refuses <see cref="TeamRole.Owner"/> outright.
|
|
/// </para>
|
|
/// <para>
|
|
/// The recipient must already be an active member. Adding somebody and handing them the team in
|
|
/// one step would let an id supplied once take it, and the reason
|
|
/// <see cref="AddMemberAsync"/> refuses the owner role is the same one.
|
|
/// </para>
|
|
/// <para>
|
|
/// The outgoing owner is demoted rather than removed. Removing them would revoke their vault key
|
|
/// grants and flag every team vault for rekey — a far larger act than the one asked for, and
|
|
/// somebody handing over a team is usually staying in it.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task TransferOwnershipAsync(
|
|
UserAccount actor,
|
|
Guid teamId,
|
|
TransferTeamOwnershipRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
if (request.UserId == actor.Id)
|
|
{
|
|
throw new TeamInvalidException("You already own this team.");
|
|
}
|
|
|
|
var outgoing = await RequireMembershipAsync(teamId, actor.Id, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// Belt and braces: the endpoint already refused anybody who is not the owner. Checking again
|
|
// here keeps the invariant with the code that enforces it rather than one layer away.
|
|
if (outgoing.Role != TeamRole.Owner)
|
|
{
|
|
throw new LastTeamOwnerException("Only this team's owner can hand it over.");
|
|
}
|
|
|
|
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var strategy = database.Database.CreateExecutionStrategy();
|
|
|
|
await strategy.ExecuteAsync(async () =>
|
|
{
|
|
var transaction = await database.Database
|
|
.BeginTransactionAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await using var _ = transaction.ConfigureAwait(false);
|
|
|
|
incoming.Role = TeamRole.Owner;
|
|
outgoing.Role = TeamRole.Admin;
|
|
|
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
}).ConfigureAwait(false);
|
|
|
|
TeamLog.OwnershipTransferred(logger, teamId, actor.Id, request.UserId);
|
|
}
|
|
|
|
/// <summary>Lists the teams the caller is an active member of.</summary>
|
|
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
|
|
UserAccount user,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var memberships = await database.TeamMemberships
|
|
.Where(m => m.UserId == user.Id
|
|
&& m.Status == MembershipStatus.Active
|
|
&& m.DeletedAtUtc == null)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (memberships.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var teamIds = memberships.Select(m => m.TeamId).ToArray();
|
|
|
|
var teams = await database.Teams
|
|
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
|
|
.OrderBy(t => t.CreatedAtUtc)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var memberCounts = await database.TeamMemberships
|
|
.Where(m => teamIds.Contains(m.TeamId)
|
|
&& m.Status == MembershipStatus.Active
|
|
&& m.DeletedAtUtc == null)
|
|
.GroupBy(m => m.TeamId)
|
|
.Select(g => new { TeamId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var vaultCounts = await database.Vaults
|
|
.Where(v => v.OwnerKind == VaultOwnerKind.Team
|
|
&& v.TeamId != null
|
|
&& teamIds.Contains(v.TeamId.Value)
|
|
&& v.DeletedAtUtc == null)
|
|
.GroupBy(v => v.TeamId!.Value)
|
|
.Select(g => new { TeamId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return
|
|
[
|
|
.. teams.Select(team => new TeamSummary(
|
|
team.Id,
|
|
team.Name,
|
|
team.Slug,
|
|
team.Description,
|
|
ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role),
|
|
memberCounts.GetValueOrDefault(team.Id),
|
|
vaultCounts.GetValueOrDefault(team.Id),
|
|
team.CreatedAtUtc)),
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists a team's members.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Available to every member, not only to admins. Whoever is about to be handed a vault key needs
|
|
/// to know who else already holds one, and a directory that only administrators can read makes
|
|
/// the sharing graph less visible to the people it is about than it is to the operator — who can
|
|
/// read it straight out of the database either way.
|
|
/// </remarks>
|
|
internal async Task<IReadOnlyList<TeamMemberSummary>> ListMembersAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var memberships = await database.TeamMemberships
|
|
.Where(m => m.TeamId == teamId && m.DeletedAtUtc == null)
|
|
.Include(m => m.User)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (memberships.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var userIds = memberships.Select(m => m.UserId).ToArray();
|
|
|
|
var enrolled = await database.UserKeys
|
|
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
|
|
.Select(k => k.UserId)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var enrolledIds = enrolled.ToHashSet();
|
|
|
|
return
|
|
[
|
|
.. memberships
|
|
.OrderByDescending(m => m.Role)
|
|
.ThenBy(m => m.CreatedAtUtc)
|
|
.Select(m => new TeamMemberSummary(
|
|
m.UserId,
|
|
m.User?.Email,
|
|
m.User?.DisplayName,
|
|
ToContract(m.Role),
|
|
ToContract(m.Status),
|
|
enrolledIds.Contains(m.UserId),
|
|
m.JoinedAtUtc,
|
|
m.User?.LastSeenAtUtc)),
|
|
];
|
|
}
|
|
|
|
/// <summary>Adds a member, or reactivates one who was removed.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
|
|
/// somebody else is a transfer rather than an addition — a different operation, with its own
|
|
/// endpoint, which demotes the outgoing owner in the same transaction. Adding somebody straight
|
|
/// to owner would hand a team to an id typed once.
|
|
/// </para>
|
|
/// <para>
|
|
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
|
|
/// which is what keeps historic audit entries resolvable to one membership. It does <em>not</em>
|
|
/// restore their revoked key grants: those were wrapped to a generation the vault has since been
|
|
/// flagged to leave behind, and a member holding Share has to wrap the key afresh.
|
|
/// </para>
|
|
/// <para>
|
|
/// Enrollment is not required of the account being added, and asking for it would be asking the
|
|
/// wrong question. A membership is authorization and grants nothing readable — that is the whole
|
|
/// of ADR 0009 — so somebody can be added on Monday and publish a key on Tuesday, which is what
|
|
/// <c>TeamMemberSummary.IsEnrolled</c> is for.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task<TeamMemberSummary> AddMemberAsync(
|
|
UserAccount actor,
|
|
Guid teamId,
|
|
AddTeamMemberRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var role = ToDomain(request.Role);
|
|
|
|
if (role is TeamRole.Unspecified or TeamRole.Owner)
|
|
{
|
|
throw new TeamInvalidException(
|
|
"Add a member as viewer, member or admin. Ownership is sole and is not transferred "
|
|
+ "by adding somebody.");
|
|
}
|
|
|
|
var target = await ResolveTargetAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
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);
|
|
|
|
// Membership is what the server will serve, so every vault this team owns has just appeared in
|
|
// the new member's list — before anybody wraps a key to them, which is a separate act and its
|
|
// own notice. Told at once rather than on their next pass. See ADR 0012.
|
|
events.VaultAccessChanged(target.Id);
|
|
|
|
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the account an add is aimed at, by id when the caller has one and by address otherwise.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The two are not interchangeable and the order matters. An id came from a directory lookup the
|
|
/// caller has already made, so it names an account whose key they have seen; an address is what is
|
|
/// left when the directory could not answer, which it cannot for anybody who has signed in but not
|
|
/// yet published a key.
|
|
/// </para>
|
|
/// <para>
|
|
/// Only the active, undeleted account matters here, and the address is matched the way the
|
|
/// directory matches it — the email column is citext, so the comparison is case-insensitive in the
|
|
/// database and the partial unique index on it means at most one row can answer.
|
|
/// </para>
|
|
/// <para>
|
|
/// Both misses are specific, and neither is a new oracle. An id confirms nothing the caller did not
|
|
/// already know from the lookup that produced it. An address is answered only for an admin or owner
|
|
/// of the team the add names — checked by the endpoint before this runs — and is the same fact the
|
|
/// member list would show them a moment later. It carries its own code so the caller can say what
|
|
/// has to happen next — that person signing in here once — rather than reporting a failure at
|
|
/// somebody who simply is not here yet.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<UserAccount> ResolveTargetAsync(
|
|
AddTeamMemberRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request.UserId != Guid.Empty)
|
|
{
|
|
return await database.Users
|
|
.SingleOrDefaultAsync(
|
|
u => u.Id == request.UserId && u.DeletedAtUtc == null,
|
|
cancellationToken)
|
|
.ConfigureAwait(false)
|
|
|
|
?? throw new NoSuchAccountException(
|
|
"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.");
|
|
}
|
|
|
|
var email = (request.Email ?? string.Empty).Trim();
|
|
|
|
if (email.Length == 0)
|
|
{
|
|
throw new TeamInvalidException(
|
|
"Say who to add: either a user id from the directory, or the email address they sign "
|
|
+ "in with.");
|
|
}
|
|
|
|
return await database.Users
|
|
.SingleOrDefaultAsync(
|
|
u => u.Email == email && u.DeletedAtUtc == null && u.Status == UserStatus.Active,
|
|
cancellationToken)
|
|
.ConfigureAwait(false)
|
|
|
|
?? throw new NoSuchAccountException(
|
|
"No account here uses that address yet. Ask them to sign in to this server once, "
|
|
+ "which is what creates the account, and then add them.");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Enrollment is looked up rather than inferred, because it is the one field on a member row that
|
|
/// is about them and not about the membership: somebody can be added on Monday and set their
|
|
/// vault up on Tuesday, and the interface has to stop offering to share with them in between.
|
|
/// </remarks>
|
|
private async Task<TeamMemberSummary> DescribeAsync(
|
|
UserAccount user,
|
|
TeamMembership membership,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var isEnrolled = await database.UserKeys
|
|
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return new TeamMemberSummary(
|
|
user.Id,
|
|
user.Email,
|
|
user.DisplayName,
|
|
ToContract(membership.Role),
|
|
ToContract(membership.Status),
|
|
isEnrolled,
|
|
membership.JoinedAtUtc,
|
|
user.LastSeenAtUtc);
|
|
}
|
|
|
|
/// <summary>Changes a member's role.</summary>
|
|
internal async Task<TeamMemberSummary> ChangeRoleAsync(
|
|
UserAccount actor,
|
|
Guid teamId,
|
|
Guid memberId,
|
|
ChangeTeamMemberRoleRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var role = ToDomain(request.Role);
|
|
|
|
if (role is TeamRole.Unspecified or TeamRole.Owner)
|
|
{
|
|
throw new TeamInvalidException(
|
|
"A member may be made a viewer, a member or an admin. Ownership is sole and is not "
|
|
+ "granted this way.");
|
|
}
|
|
|
|
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// Demoting the owner here would leave the team ownerless, because this operation cannot
|
|
// appoint a replacement in the same breath. Transferring can, and does both at once — so
|
|
// the refusal names it rather than saying the thing is impossible.
|
|
if (membership.Role == TeamRole.Owner)
|
|
{
|
|
throw new LastTeamOwnerException(
|
|
"This team's owner cannot be demoted on its own. Transfer ownership to another "
|
|
+ "member instead: that hands the team over and makes the outgoing owner an admin, "
|
|
+ "in one step, so the team is never left with nobody who can manage it.");
|
|
}
|
|
|
|
membership.Role = role;
|
|
|
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id);
|
|
|
|
// The account cannot be missing — a membership has a foreign key to it — but the query is
|
|
// written to tolerate it rather than to assert, because a null here would become an
|
|
// exception on a change that has already been committed.
|
|
var user = await database.Users
|
|
.SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return user is null
|
|
? new TeamMemberSummary(
|
|
memberId, null, null, ToContract(role), ToContract(membership.Status), false,
|
|
membership.JoinedAtUtc)
|
|
: await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a member, revoking every vault key grant they hold from this team.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One transaction, because the two halves are not separable: a membership revoked without its
|
|
/// grants leaves a departed member holding a key the server will happily keep serving, and grants
|
|
/// revoked without the membership leaves an active member whose vaults have silently stopped
|
|
/// opening.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every affected vault is flagged <c>RekeyRequired</c> rather than rekeyed. A rekey re-wraps
|
|
/// every item's data key under a new vault key and can only be performed by a client that holds
|
|
/// the current one; the server can record that one is owed and nothing more. That is M5's key
|
|
/// rotation, and until it lands the flag is what the interface reads to say so out loud.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task RemoveMemberAsync(
|
|
UserAccount actor,
|
|
Guid teamId,
|
|
Guid memberId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (membership.Role == TeamRole.Owner)
|
|
{
|
|
throw new LastTeamOwnerException(
|
|
"This team's owner cannot be removed while they own it, because that would leave the "
|
|
+ "team with nobody who can manage it. Transfer ownership to another member first — "
|
|
+ "the outgoing owner becomes an admin and can then be removed like anybody else.");
|
|
}
|
|
|
|
var now = clock.GetUtcNow();
|
|
var strategy = database.Database.CreateExecutionStrategy();
|
|
|
|
var revoked = await strategy.ExecuteAsync(async () =>
|
|
{
|
|
var transaction = await database.Database
|
|
.BeginTransactionAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await using var _ = transaction.ConfigureAwait(false);
|
|
|
|
membership.Status = MembershipStatus.Revoked;
|
|
membership.DeletedAtUtc = now;
|
|
|
|
var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return count;
|
|
}).ConfigureAwait(false);
|
|
|
|
TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);
|
|
|
|
// After the commit, so their client re-reads a list the server has already stopped serving
|
|
// those vaults from. Their open socket re-resolves as it forwards this, which is what stops it
|
|
// announcing changes to vaults they have just lost.
|
|
events.VaultAccessChanged(memberId);
|
|
}
|
|
|
|
/// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
|
|
private async Task<int> RevokeTeamGrantsAsync(
|
|
Guid teamId,
|
|
Guid memberId,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var vaults = await database.Vaults
|
|
.Where(v => v.TeamId == teamId
|
|
&& v.OwnerKind == VaultOwnerKind.Team
|
|
&& v.DeletedAtUtc == null)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (vaults.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var vaultIds = vaults.Select(v => v.Id).ToArray();
|
|
|
|
var grants = await database.VaultKeyGrants
|
|
.Where(g => vaultIds.Contains(g.VaultId)
|
|
&& g.RecipientUserId == memberId
|
|
&& g.RevokedAtUtc == null)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (var grant in grants)
|
|
{
|
|
grant.State = GrantState.Revoked;
|
|
grant.RevokedAtUtc = now;
|
|
}
|
|
|
|
// Flagged whether or not this member held a grant. Somebody who was a member without a key
|
|
// still saw the vault's existence, its item count and its plaintext columns, and the vault's
|
|
// key is what a rekey would change — so "they never had a grant" is not a reason to leave the
|
|
// flag clear.
|
|
foreach (var vault in vaults)
|
|
{
|
|
vault.RekeyRequired = true;
|
|
vault.RekeyReason = RekeyReason.MemberRemoved;
|
|
vault.UpdatedAtUtc = now;
|
|
}
|
|
|
|
return grants.Count;
|
|
}
|
|
|
|
/// <summary>Reads the caller's own membership, for authorization checks.</summary>
|
|
internal Task<TeamMembership?> FindActiveMembershipAsync(
|
|
Guid teamId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken) =>
|
|
database.TeamMemberships.SingleOrDefaultAsync(
|
|
m => m.TeamId == teamId
|
|
&& m.UserId == userId
|
|
&& m.Status == MembershipStatus.Active
|
|
&& m.DeletedAtUtc == null,
|
|
cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Resolves what the caller may do with a team.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Answers <see cref="TeamAccess.Denied"/> identically for a team that does not exist and one the
|
|
/// caller is not in, for the reason <c>VaultAccessService</c> gives: a distinct "exists but
|
|
/// forbidden" is an oracle for other tenants' team ids.
|
|
/// </remarks>
|
|
internal async Task<TeamAccess> ResolveAsync(
|
|
Guid userId,
|
|
Guid teamId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var team = await database.Teams
|
|
.SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (team is null)
|
|
{
|
|
return TeamAccess.Denied;
|
|
}
|
|
|
|
var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role);
|
|
}
|
|
|
|
private async Task<TeamMembership> RequireMembershipAsync(
|
|
Guid teamId,
|
|
Guid memberId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var membership = await database.TeamMemberships
|
|
.SingleOrDefaultAsync(
|
|
m => m.TeamId == teamId
|
|
&& m.UserId == memberId
|
|
&& m.Status == MembershipStatus.Active
|
|
&& m.DeletedAtUtc == null,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return membership
|
|
?? throw new TeamInvalidException("That account is not an active member of this team.");
|
|
}
|
|
|
|
/// <summary>Counts a team's active members.</summary>
|
|
private Task<int> CountMembersAsync(Guid teamId, CancellationToken cancellationToken) =>
|
|
database.TeamMemberships.CountAsync(
|
|
m => m.TeamId == teamId
|
|
&& m.Status == MembershipStatus.Active
|
|
&& m.DeletedAtUtc == null,
|
|
cancellationToken);
|
|
|
|
/// <summary>Counts the vaults a team owns.</summary>
|
|
/// <remarks>
|
|
/// Filtered on <c>OwnerKind</c> as well as on the id, matching <see cref="ListAsync"/>. A vault
|
|
/// carrying a team id it does not belong to would otherwise be counted here and not there, and
|
|
/// this count is what decides whether a team may be archived.
|
|
/// </remarks>
|
|
private Task<int> CountVaultsAsync(Guid teamId, CancellationToken cancellationToken) =>
|
|
database.Vaults.CountAsync(
|
|
v => v.TeamId == teamId
|
|
&& v.OwnerKind == VaultOwnerKind.Team
|
|
&& v.DeletedAtUtc == null,
|
|
cancellationToken);
|
|
|
|
/// <remarks>
|
|
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
|
|
/// else under an id that is already taken is refused: silently returning somebody else's team
|
|
/// would be an existence oracle, and returning a differently-named one would tell a client its
|
|
/// rename succeeded when nothing changed.
|
|
/// </remarks>
|
|
private async Task<TeamSummary> ResolveExistingAsync(
|
|
UserAccount user,
|
|
Team existing,
|
|
string name,
|
|
string slug,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var isRetry = membership?.Role == TeamRole.Owner
|
|
&& existing.DeletedAtUtc == null
|
|
&& string.Equals(existing.Name, name, StringComparison.Ordinal)
|
|
&& string.Equals(existing.Slug, slug, StringComparison.Ordinal);
|
|
|
|
if (!isRetry)
|
|
{
|
|
throw new TeamInvalidException(
|
|
"That team id is already in use. Generate a new UUIDv7 and retry.");
|
|
}
|
|
|
|
var memberCount = await database.TeamMemberships
|
|
.CountAsync(
|
|
m => m.TeamId == existing.Id
|
|
&& m.Status == MembershipStatus.Active
|
|
&& m.DeletedAtUtc == null,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var vaultCount = await database.Vaults
|
|
.CountAsync(
|
|
v => v.TeamId == existing.Id && v.DeletedAtUtc == null,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return new TeamSummary(
|
|
existing.Id, existing.Name, existing.Slug, existing.Description,
|
|
TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates a slug.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
|
|
/// purpose: the column is <c>citext</c>, so a slug differing only in case is the same slug, and a
|
|
/// value that renders differently from how it compares is how two teams end up looking distinct
|
|
/// in a list and colliding on insert.
|
|
/// </remarks>
|
|
private static string RequireSlug(string? value)
|
|
{
|
|
var slug = (value ?? string.Empty).Trim();
|
|
|
|
if (slug.Length is 0 or > MaxSlugLength)
|
|
{
|
|
throw new TeamInvalidException(
|
|
$"A slug of 1 to {MaxSlugLength} characters is required.");
|
|
}
|
|
|
|
var previousWasHyphen = false;
|
|
|
|
for (var index = 0; index < slug.Length; index++)
|
|
{
|
|
var character = slug[index];
|
|
var isHyphen = character == '-';
|
|
|
|
var acceptable = (character is >= 'a' and <= 'z')
|
|
|| (character is >= '0' and <= '9')
|
|
|| isHyphen;
|
|
|
|
if (!acceptable
|
|
|| (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1)))
|
|
{
|
|
throw new TeamInvalidException(
|
|
"A slug is lowercase letters, digits and single hyphens, and cannot start or end "
|
|
+ "with a hyphen.");
|
|
}
|
|
|
|
previousWasHyphen = isHyphen;
|
|
}
|
|
|
|
return slug;
|
|
}
|
|
|
|
private static string RequireText(string? value, string field, int maxLength)
|
|
{
|
|
var text = (value ?? string.Empty).Trim();
|
|
|
|
if (text.Length == 0 || text.Length > maxLength)
|
|
{
|
|
throw new TeamInvalidException(
|
|
string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{field} is required, and at most {maxLength} characters."));
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
private static string? OptionalText(string? value, int maxLength)
|
|
{
|
|
var text = value?.Trim();
|
|
|
|
if (string.IsNullOrEmpty(text))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (text.Length > maxLength)
|
|
{
|
|
throw new TeamInvalidException(
|
|
string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"A description is at most {maxLength} characters."));
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A plain cast, which is why <c>TeamMemberRole</c> pins the same numeric values as
|
|
/// <see cref="TeamRole"/> and a test asserts it. An unknown value becomes
|
|
/// <see cref="TeamRole.Unspecified"/> rather than a silent cast to a role nobody defined, so a
|
|
/// newer client's role is refused instead of resolving to whatever bit pattern it happens to be.
|
|
/// </remarks>
|
|
private static TeamRole ToDomain(TeamMemberRole role) => role switch
|
|
{
|
|
TeamMemberRole.Viewer => TeamRole.Viewer,
|
|
TeamMemberRole.Member => TeamRole.Member,
|
|
TeamMemberRole.Admin => TeamRole.Admin,
|
|
TeamMemberRole.Owner => TeamRole.Owner,
|
|
_ => TeamRole.Unspecified,
|
|
};
|
|
|
|
private static TeamMemberRole ToContract(TeamRole role) => role switch
|
|
{
|
|
TeamRole.Viewer => TeamMemberRole.Viewer,
|
|
TeamRole.Member => TeamMemberRole.Member,
|
|
TeamRole.Admin => TeamMemberRole.Admin,
|
|
TeamRole.Owner => TeamMemberRole.Owner,
|
|
_ => TeamMemberRole.Unspecified,
|
|
};
|
|
|
|
private static TeamMemberStatus ToContract(MembershipStatus status) => status switch
|
|
{
|
|
MembershipStatus.Invited => TeamMemberStatus.Invited,
|
|
MembershipStatus.Active => TeamMemberStatus.Active,
|
|
MembershipStatus.Revoked => TeamMemberStatus.Revoked,
|
|
_ => TeamMemberStatus.Unspecified,
|
|
};
|
|
|
|
private static bool IsUniqueViolation(DbUpdateException exception) =>
|
|
string.Equals(
|
|
(exception.InnerException as PostgresException)?.SqlState,
|
|
PostgresErrorCodes.UniqueViolation,
|
|
StringComparison.Ordinal);
|
|
}
|