Public Access
Server: stop a team being archived while a vault is created in it
This commit is contained in:
1 parent
ff8a790f6c
commit
965c9a7043
3 files changed
+91
-23
No files matched your search
@@ -0,0 +1,27 @@
|
|||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Teams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises the writes that decide what a team owns, for the life of the transaction.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
|
||||||
|
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
|
||||||
|
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
|
||||||
|
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
|
||||||
|
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TeamLock
|
||||||
|
{
|
||||||
|
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
|
||||||
|
database.Database.ExecuteSqlAsync(
|
||||||
|
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
@@ -255,16 +255,6 @@ internal sealed class TeamService(
|
|||||||
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 now = clock.GetUtcNow();
|
||||||
var archived = await database.ExecuteFreshAsync([team], async () =>
|
var archived = await database.ExecuteFreshAsync([team], async () =>
|
||||||
{
|
{
|
||||||
@@ -273,6 +263,19 @@ internal sealed class TeamService(
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await using var _ = transaction.ConfigureAwait(false);
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
|
||||||
|
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
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 memberships = await database.TeamMemberships
|
var memberships = await database.TeamMemberships
|
||||||
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
|
|||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
|
||||||
{
|
|
||||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
|
||||||
// the same id, which must not surface as a 500 about a constraint.
|
|
||||||
throw new TeamInvalidException(
|
|
||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
|
||||||
}
|
|
||||||
|
|
||||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||||
|
|
||||||
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
|
|||||||
RekeyRequired: false);
|
RekeyRequired: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
|
||||||
|
private Task WriteVaultAsync(
|
||||||
|
UserAccount user,
|
||||||
|
Team team,
|
||||||
|
CreateTeamVaultRequest request,
|
||||||
|
string name,
|
||||||
|
UserKey key,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
database.ExecuteFreshAsync([team], async () =>
|
||||||
|
{
|
||||||
|
var transaction = await database.Database
|
||||||
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
|
||||||
|
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||||
|
{
|
||||||
|
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||||
|
// the same id, which must not surface as a 500 about a constraint.
|
||||||
|
throw new TeamInvalidException(
|
||||||
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
|
||||||
|
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var live = await database.Teams
|
||||||
|
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!live)
|
||||||
|
{
|
||||||
|
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||||
private void AddVaultWithSelfGrant(
|
private void AddVaultWithSelfGrant(
|
||||||
UserAccount user,
|
UserAccount user,
|
||||||
|
|||||||
Reference in new issue
Block a user