Server: stop a team being archived while a vault is created in it

This commit is contained in:
jaap-jan committed 2026-10-02 14:20:30 +02:00
1 parent ff8a790f6c
commit 965c9a7043
3 files changed
+86 -18

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);
}
+11 -8
View File
@@ -255,6 +255,17 @@ 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 now = clock.GetUtcNow();
var archived = await database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.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); var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0) if (vaultCount > 0)
@@ -265,14 +276,6 @@ internal sealed class TeamService(
$"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.")); $"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 archived = await database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
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,6 +77,40 @@ 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.");
} }
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
return new VaultSummary(
VaultId: request.VaultId,
Name: name,
IsPersonal: false,
TeamId: team.Id,
KeyGeneration: 1,
Permissions: 0,
WrappedVaultKey: request.WrappedVaultKey,
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()); AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try try
@@ -90,17 +125,20 @@ 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.");
} }
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
});
return new VaultSummary( /// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
VaultId: request.VaultId, private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
Name: name, {
IsPersonal: false, var live = await database.Teams
TeamId: team.Id, .AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
KeyGeneration: 1, .ConfigureAwait(false);
Permissions: 0,
WrappedVaultKey: request.WrappedVaultKey, if (!live)
RekeyRequired: false); {
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>