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

@@ -192,6 +192,69 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
user.EnrolledAtUtc.ShouldNotBeNull();
}
/// <remarks>
/// Suspended means "sign-in blocked", and nothing used to block it: the identity provider still vouches
/// for the person, and every endpoint resolved them by issuer and subject alone. The status is this
/// server's decision, so this server has to be the one refusing — on its own endpoints and on vaults.
/// </remarks>
[Fact]
public async Task ASuspendedAccount_IsRefusedEverywhereWithACodeSayingWhy()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
await database.Users
.Where(u => u.Subject == enrollment.Subject)
.ExecuteUpdateAsync(setters => setters.SetProperty(u => u.Status, UserStatus.Suspended));
}
await ShouldBeProblemAsync(
await client.GetAsync(new Uri(MeUrl, UriKind.Relative)),
HttpStatusCode.Forbidden,
ProblemCodes.AccountNotActive);
await ShouldBeProblemAsync(
await client.PostContractAsync(
$"/api/v1/vaults/{enrollment.VaultId}/sync/pull", new SyncPullRequest(null, null, null)),
HttpStatusCode.Forbidden,
ProblemCodes.AccountNotActive);
}
/// <remarks>
/// The last-seen stamp is written past the change tracker once an hour, which moves the row's
/// concurrency token under the entity the request is holding. Enrolment then saved that same user and
/// failed on the stale token — so anyone who signed in, left, and came back an hour later to finish
/// setting up got a 500 on their first try.
/// </remarks>
[Fact]
public async Task Enroll_MoreThanAnHourAfterFirstSigningIn_Succeeds()
{
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
(await client.GetAsync(new Uri(MeUrl, UriKind.Relative))).EnsureSuccessStatusCode();
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
await database.Users
.Where(u => u.Subject == enrollment.Subject)
.ExecuteUpdateAsync(setters => setters.SetProperty(
u => u.LastSeenAtUtc, TimeProvider.System.GetUtcNow().AddHours(-2)));
}
var response = await EnrollAsync(client, enrollment.Build());
response.KeyGeneration.ShouldBe(1);
}
[Fact]
public async Task Enroll_AppendsToTheKeyLogAndTheEntryReproducesItsOwnHash()
{