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

@@ -368,6 +368,42 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.ShouldNotContain(row => row.TeamId == team.TeamId, "the team existed to carry that vault");
}
/// <remarks>
/// The archive endpoint refuses an admin, and deleting a team's last vault archives the team too — so
/// without the same rule there, an admin could archive the team out from under its owner one vault at a
/// time, revoking the owner's membership on the way. The delete is theirs to make; the team is not.
/// </remarks>
[Fact]
public async Task AnAdminDeletingTheLastVault_DeletesItButLeavesTheTeamToItsOwner()
{
var owner = await EnrolledClientAsync("vault-delete-admin-owner", "vdaowner@example.com");
var admin = await EnrolledClientAsync("vault-delete-admin-admin", "vdaadmin@example.com");
var team = await CreateTeamAsync(owner, "Owned");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "vdaadmin@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
(await DeleteAsync(admin, VaultUrl(vaultId))).StatusCode.ShouldBe(HttpStatusCode.NoContent);
(await ReadAsync<MeResponse>(owner, MeUrl)).Vaults
.ShouldNotContain(vault => vault.VaultId == vaultId);
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
.ShouldContain(row => row.TeamId == team.TeamId, "only the owner archives a team");
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
(await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
.Where(row => row.UserId == ownerMe.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// A team carrying several vaults keeps its own life when one of them goes, which is the same line the
/// rename draws — and the other vault has to survive, which is the assertion that a deletion aimed at