Server: send each item at the revision its ciphertext is sealed at, keep team archiving with the owner, refuse suspended accounts, and stop enrolment failing after an hour

- Pull hydrated every historical change entry with the item's current ciphertext under that entry's
  old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and
  parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the
  later entry carries the same payload under the right number, and a fresh device no longer downloads
  an item once per edit it ever had.
- Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out
  from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks.
- UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers
  403 account-not-active for a suspended or deprovisioned account.
- The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user
  against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The
  entity is reloaded after the update.
This commit is contained in:
jaap-jan committed 2026-10-02 13:16:48 +02:00
1 parent f332d9a56f
commit 6846f67c20
14 files changed
+277 -12

No files matched your search

@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
}
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
// checks it again, because deleting a team's last vault archives it too.
if (!access.IsOwner)
{
return Problems.Coded(
@@ -244,8 +244,16 @@ internal sealed class TeamService(
Team team,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(actor);
ArgumentNullException.ThrowIfNull(team);
// Both callers check this first and answer the caller properly; this is what stops a third one that
// forgets from letting an admin archive the team out from under its owner.
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
{
throw new InvalidOperationException("Only the owner of a team can archive it.");
}
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
{
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
if (orphaned is not null)
// Archived only by its owner. Archiving revokes every membership, the owner's included, and an
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
// the owner, who can archive it or make a vault in it.
if (orphaned is not null
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
{
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
}