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

+19 -4
View File
@@ -536,9 +536,21 @@ internal sealed class SyncService(
/// <summary>Attaches current row state to change-log entries.</summary>
/// <remarks>
/// <para>
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
/// </para>
/// <para>
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
/// </para>
/// </remarks>
private async Task<List<Contracts.SyncChange>> HydrateAsync(
Vault vault,
@@ -571,10 +583,13 @@ internal sealed class SyncService(
}
}
return [.. changes.Select(change => Describe(
change,
kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
return [.. changes
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|| row.ChangeSequence <= change.Sequence)
.Select(change => Describe(
change,
kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
}
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>