Public Access
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:
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()
|
||||
{
|
||||
|
||||
@@ -276,6 +276,52 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
second.Changes.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An item edited several times is pulled once, at the revision its ciphertext is sealed at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The table holds only the newest payload, and its AAD binds the newest version. Hydrating each older
|
||||
/// log entry with it paired revision 1 or 2 with a payload sealed at 3, which no client can open — and a
|
||||
/// client that could not open it parked its own pending edit as undecryptable.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Pull_OfAnItemEditedSinceItsEarlierEntries_SendsOnlyItsCurrentRevision()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var create = NewCreateBatch();
|
||||
var entityId = create.Operations[0].EntityId;
|
||||
await client.PostContractAsync(PushUrl(vaultId), create);
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]),
|
||||
]));
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
|
||||
]));
|
||||
|
||||
var pulled = await (await client.PostContractAsync(
|
||||
PullUrl(vaultId),
|
||||
new SyncPullRequest(null, null, null))).Content.ReadContractAsync<SyncPullResponse>();
|
||||
|
||||
pulled.ShouldNotBeNull();
|
||||
|
||||
var change = pulled.Changes.ShouldHaveSingleItem();
|
||||
change.Version.ShouldBe(3);
|
||||
change.Payload!.Envelope.ShouldBe([3, 3, 3]);
|
||||
|
||||
// And the cursor is past all three entries, so the next pull does not offer the old ones again.
|
||||
var next = await (await client.PostContractAsync(
|
||||
PullUrl(vaultId),
|
||||
new SyncPullRequest(pulled.NextCursor, null, null))).Content.ReadContractAsync<SyncPullResponse>();
|
||||
|
||||
next!.Changes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_WithACursorFromAnotherVault_Is400()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user