Files
DodoSSH/src/DodoSSH.Client.Storage/VaultStore.cs
T
jaap-jan 7b7fd7b2ef Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way
round. A vault has to belong to a team, so creating one meant going to the teams
screen, founding an organisation, and only then adding a vault to it — which the
NEW VAULT button named after the team, so a team with three of them held three
vaults called the same thing and nothing told them apart. Somebody who wants to
share four servers with two colleagues is not asking to found anything.

So the form asks for a name and nothing else. The team is derived from it, slug
included, and created with this account as its owner; the vault goes inside; and
the members, roles, invitations and key holders that hang off a team are all on
screen the moment it exists. The tab strip's New vault entry lands there with the
new vault selected, which is where the next thing anybody wants to do already is.

That is two calls, and the first can succeed alone. When it does the team is
kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again
resends the identical create — which the server treats as the same team — and
retries the vault, and the message says all of that rather than "creating the
vault failed". Archiving the orphan instead would be a client deleting something
on the user's behalf because a later step failed, which is the kind of tidying
that eventually archives a team somebody has just been added to. A slug taken by
somebody else is retried once with a disambiguated one and never in a loop; a
name with no a-z or 0-9 anywhere in it falls back to the team's own id rather
than to a refusal pointing at a field nobody was shown.

The other half is the caret beside Vaults. Being in four teams means four teams'
machines in front of you all day, and the answer is a switch per vault rather
than four sign-ins. Switching one off takes its hosts, groups, keys and pins off
the screens that list them and does nothing else: it still syncs, its key stays
in the keyring, it stays choosable as somewhere to file a new item, and a shown
host that authenticates with a key filed in it still connects. That last one is
what shaped the design. TryBuildAuthentication resolves a binding out of the
keychain's typed list and a cross-vault binding is legal, so filtering the reload
loops — the obvious implementation — would have turned a preference about reading
into an outage. Only the projections a person reads consult IsVaultShown; every
Reload*Async stays whole, including the dialled-endpoint set that decides which
pins are described as unused, because that is a hint which invites deleting
trust.

Snippets, logs and buckets needed no code and the comment says so out loud: all
three read ActiveVaultId alone, and the personal vault is drawn in the menu
ticked and cannot be switched off — it is the active vault, the group and tag
editors' target, and the save picker's fallback, so hiding it would empty half
the application rather than filter it.

The preference is a column on the cache's vault row, which is what makes it
survive both a relaunch and the /me refresh that runs every minute: Apply does
not touch it, deliberately, because the server has never been told which vaults
this machine is showing. It is in the encrypted cache rather than settings.json
because it is a list of vault ids and that file's own doc comment says what may
go in it. VaultSession cannot see the type at all — ReadableVaults is what the
sync loop walks, and a filter reaching it would be a vault that quietly stopped
syncing, found out weeks later from a host that was never there.

The strip's note refusing a MenuFlyout stands and is unchanged. This flyout
sidesteps the question rather than answering it: the handler selects the Vaults
tab first, which collapses the renderer, so nothing native is under the popup by
the time it opens — the move QuickConnect already makes. A headless test asserts
that ordering, which is as far as headless can go with no native window, and
manual check 1.6 is the other half.

The phone is out of scope on purpose: it has no tab strip and its teams screen's
vault section is read-only. The plumbing is in Client.Shell, so it can adopt this
later; until then nothing there is ever hidden, which is today's behaviour.

1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth
naming are the guards: a hidden vault still syncs, still holds keys that
authenticate hosts on screen, still appears in the save picker, and still counts
towards which pins nothing dials.

Not fixed, and noted here because it is next door: VaultGrantService's team-vault
create refuses a taken vault id rather than returning the existing vault, while
VaultSharing's own remark claims a create whose response was lost is safe to
resend. A lost 200 therefore leaves a vault whose key the client's catch already
zeroed, openable by nobody.
2026-08-03 21:52:27 +02:00

196 lines
7.4 KiB
C#

using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// The vaults this user can reach, and the grants that open them.
/// </summary>
/// <remarks>
/// Cached for the same reason as the unlock material: without the wrapped vault key on disk, an offline
/// launch could unlock the identity bundle and still not decrypt a single item. Every value here is
/// either public metadata or ciphertext.
/// </remarks>
public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
{
/// <summary>Reads every known vault.</summary>
public async Task<IReadOnlyList<StoredVault>> ListAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set<CachedVaultRow>()
.AsNoTracking()
.OrderByDescending(row => row.IsPersonal)
.ThenBy(row => row.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
}
/// <summary>Reads one vault.</summary>
public async Task<StoredVault?> FindAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedVaultRow>()
.AsNoTracking()
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
/// <summary>
/// Replaces the cached vault list with what the server reported.
/// </summary>
/// <remarks>
/// <para>
/// Vaults absent from the list are removed, because losing access to a vault is exactly what that
/// absence means and a stale row would offer the user a vault they can no longer sync.
/// </para>
/// <para>
/// Their <em>items</em> are a separate matter and are not touched here. Removing a member does not
/// retroactively erase what they already hold — that is not achievable, which is why offboarding
/// means rotating the SSH credential rather than revoking a grant. Deleting the local rows here
/// would only make the client pretend otherwise.
/// </para>
/// </remarks>
public async Task ReplaceAllAsync(
IReadOnlyList<StoredVault> vaults,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(vaults);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var existing = await context.Set<CachedVaultRow>()
.ToDictionaryAsync(row => row.VaultId, cancellationToken)
.ConfigureAwait(false);
var now = clock.GetUtcNow();
foreach (var vault in vaults)
{
if (!existing.Remove(vault.VaultId, out var row))
{
row = new CachedVaultRow { VaultId = vault.VaultId };
context.Add(row);
}
Apply(row, vault, now);
}
context.RemoveRange(existing.Values);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Records one vault, leaving the rest alone.
/// </summary>
/// <remarks>
/// For the vault this machine has just created, which exists here before the server's next
/// <c>/me</c> confirms it. <see cref="ReplaceAllAsync"/> would be wrong for that: it treats absence
/// as loss of access, and the one list that does not yet mention this vault is the one this client
/// last fetched.
/// </remarks>
public async Task UpsertAsync(StoredVault vault, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(vault);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedVaultRow>()
.SingleOrDefaultAsync(r => r.VaultId == vault.VaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new CachedVaultRow { VaultId = vault.VaultId };
context.Add(row);
}
Apply(row, vault, clock.GetUtcNow());
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Reads the vaults this machine has been asked to leave off the screens.</summary>
/// <remarks>
/// The ids alone: a caller wanting the names already has <see cref="ListAsync"/>, and a list that
/// carried them would invite somebody to build a vault list out of this one, which is the list that
/// must never decide what syncs.
/// </remarks>
public async Task<IReadOnlyList<Guid>> ListHiddenAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<CachedVaultRow>()
.AsNoTracking()
.Where(row => row.Hidden)
.Select(row => row.VaultId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Records whether one vault's items are shown.</summary>
/// <remarks>
/// A vault with no row here is not an error and not worth reporting: a grant withdrawn between the
/// click and this write leaves nothing to record a preference about, and the vault is already gone
/// from every list the preference would have applied to.
/// </remarks>
public async Task SetHiddenAsync(Guid vaultId, bool hidden, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedVaultRow>()
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null || row.Hidden == hidden)
{
return;
}
row.Hidden = hidden;
row.UpdatedAtUtc = clock.GetUtcNow();
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <remarks>
/// <see cref="CachedVaultRow.Hidden"/> is deliberately not among these. This method exists to write
/// what the server said, and the server has never been told which vaults this machine is currently
/// showing — so a refresh that touched the flag would be a refresh that silently un-hid every vault,
/// once a minute. Leaving it out is what makes the preference survive <see cref="ReplaceAllAsync"/>.
/// </remarks>
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
{
row.Name = vault.Name;
row.IsPersonal = vault.IsPersonal;
row.TeamId = vault.TeamId;
row.KeyGeneration = vault.KeyGeneration;
row.Permissions = vault.Permissions;
row.WrappedVaultKey = vault.WrappedVaultKey;
row.RekeyRequired = vault.RekeyRequired;
row.UpdatedAtUtc = now;
}
private static StoredVault ToStored(CachedVaultRow row) =>
new(
row.VaultId,
row.Name,
row.IsPersonal,
row.TeamId,
row.KeyGeneration,
row.Permissions,
row.WrappedVaultKey,
row.RekeyRequired);
}