Merge page blocks a shared provider before consent; requested-undo notice, no passkey promise, every access prunes bearers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e60953e469
commit
63b9596ff1
6 changed files with 48 additions and 8 deletions
|
|
@ -65,6 +65,10 @@ public static class MergePreview
|
|||
_ => "These accounts cannot be merged now."
|
||||
};
|
||||
|
||||
/// <summary>The block for two accounts that both hold a login of one provider, which Heimdall will not combine. Providers only: no account is named.</summary>
|
||||
public static string ProviderConflict(IEnumerable<string> providerNames) =>
|
||||
$"Both accounts hold a {string.Join(" and ", providerNames)} login, and Heimdall will not combine two logins of one provider. Unlink one of them first.";
|
||||
|
||||
/// <summary>What every merge says, whatever the plan: what it cannot carry over, what it asks again, and how long it can be undone.</summary>
|
||||
public static IReadOnlyList<string> Statements { get; } =
|
||||
[
|
||||
|
|
@ -74,7 +78,7 @@ public static class MergePreview
|
|||
"Roles and admin rights of the other account end; a steward must grant them again.",
|
||||
"You will have one membership and one vote per motion: where both accounts voted on an open motion, the kept account's vote counts and the other is set aside. Nothing is added together.",
|
||||
"You can undo this for 7 days, from Merge status. After that it is final.",
|
||||
"If you proved an account with ordinary sign-ins rather than a passkey, the merge is approved with ordinary sign-ins, which is the weaker proof, and a notice stays on your account until the 7 days end."
|
||||
"The merge is approved with ordinary sign-ins, which is the weaker proof, and a notice stays on your account until the 7 days end."
|
||||
];
|
||||
|
||||
private static string Count(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s";
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ public abstract record PeekResult
|
|||
|
||||
/// <summary>Logins whose provider has no live proof, by provider name only.</summary>
|
||||
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : PeekResult;
|
||||
|
||||
/// <summary>Both accounts hold a login of these providers, a pair Heimdall always refuses; by provider name only.</summary>
|
||||
public sealed record ProviderConflict(IReadOnlyList<string> Providers) : PeekResult;
|
||||
}
|
||||
|
||||
public abstract record UndoPeekResult
|
||||
|
|
@ -58,7 +61,7 @@ public abstract record UndoPeekResult
|
|||
/// the member proves again. It decides nothing about the merge: the saga owns consent, the move and the lifecycle, and Heimdall judges
|
||||
/// each proof's tier and freshness. <see cref="ProofLifetime"/> is only a margin under Heimdall's 300 s weak window, so a stale proof
|
||||
/// is not sent to fail a merge. Provider is the only proof bookkeeping Bifrost has (the redeem names the provider, not the identity),
|
||||
/// so a login list holding two logins of one provider is refused. Peek never consumes: the caller <see cref="Clear"/>s once the saga
|
||||
/// so a login list holding two logins of one provider is refused, and two sides holding a login of one provider are a conflict Peek names before consent. Every access drops expired bearers. Peek never consumes: the caller <see cref="Clear"/>s once the saga
|
||||
/// has taken the proofs, so a refusal that wrote nothing does not cost a re-proof.
|
||||
/// </summary>
|
||||
public sealed class MergeProofs(TimeProvider timeProvider)
|
||||
|
|
@ -136,6 +139,12 @@ public sealed class MergeProofs(TimeProvider timeProvider)
|
|||
return new PeekResult.Expired();
|
||||
}
|
||||
|
||||
var shared = survivor.Logins.Select(x => x.Provider).Intersect(folded.Logins.Select(x => x.Provider), StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
|
||||
if (shared.Length > 0)
|
||||
{
|
||||
return new PeekResult.ProviderConflict(shared);
|
||||
}
|
||||
|
||||
var unproved = UnprovedProviders(folded, folded.Logins.Select(x => x.Provider));
|
||||
return unproved.Count > 0
|
||||
? new PeekResult.LoginsUnproved(unproved)
|
||||
|
|
@ -172,6 +181,7 @@ public sealed class MergeProofs(TimeProvider timeProvider)
|
|||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Prune(timeProvider.GetUtcNow());
|
||||
_held.Remove(actorId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ public static class MergeWording
|
|||
|
||||
public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status.";
|
||||
|
||||
public static string UndoRequestedNotice() => "A merge into your account is being undone: Heimdall is giving the logins back. Review it on Merge status.";
|
||||
|
||||
public static string FrozenNotice() => "A merge is in progress; you cannot act until it settles.";
|
||||
|
||||
public static string FoldedNotice(DateTimeOffset mergedOn) =>
|
||||
|
|
|
|||
|
|
@ -144,6 +144,9 @@ public sealed class MergeAccountsModel(
|
|||
BlockWords = [.. plan.Blocks.Select(MergePreview.Block)];
|
||||
switch (proofs.Peek(me, survivorSide))
|
||||
{
|
||||
case PeekResult.ProviderConflict conflict:
|
||||
BlockWords = [.. BlockWords, MergePreview.ProviderConflict(conflict.Providers.Select(ProviderName))];
|
||||
break;
|
||||
case PeekResult.Ready when plan.Blocks.Count == 0:
|
||||
Stage = MergeStage.Confirm;
|
||||
break;
|
||||
|
|
@ -184,7 +187,13 @@ public sealed class MergeAccountsModel(
|
|||
|
||||
var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id;
|
||||
var held = proofs.Snapshot(me);
|
||||
if (held.Self is not { } self || held.Other is not { } other || proofs.Peek(me, survivorSide) is not PeekResult.Ready ready)
|
||||
var peeked = proofs.Peek(me, survivorSide);
|
||||
if (peeked is PeekResult.ProviderConflict)
|
||||
{
|
||||
return Refused(keep, "blocked");
|
||||
}
|
||||
|
||||
if (held.Self is not { } self || held.Other is not { } other || peeked is not PeekResult.Ready ready)
|
||||
{
|
||||
return Refused(keep, "proofs_needed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,13 +43,15 @@ public sealed class MergeNotice(ICurrentBifrostActorAccessor actors, BifrostDbCo
|
|||
return null;
|
||||
}
|
||||
|
||||
var graceEnds = await dbContext.AccountMerges.AsNoTracking()
|
||||
var open = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => x.SurvivorUserAccountId == account.Id
|
||||
&& (x.State == AccountMergeState.Committed || x.State == AccountMergeState.UndoRequested)
|
||||
&& x.GraceEndsUtc > now)
|
||||
.OrderBy(x => x.GraceEndsUtc)
|
||||
.Select(x => x.GraceEndsUtc)
|
||||
.Select(x => new { x.State, x.GraceEndsUtc })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return graceEnds is { } end ? MergeWording.SurvivorNotice(MergeWording.DaysUntil(end, now)) : null;
|
||||
return open is null ? null
|
||||
: open.State == AccountMergeState.UndoRequested ? MergeWording.UndoRequestedNotice()
|
||||
: MergeWording.SurvivorNotice(MergeWording.DaysUntil(open.GraceEndsUtc!.Value, now));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,13 +96,13 @@ public sealed class MergeProofsTests
|
|||
{
|
||||
var discord = Login("discord");
|
||||
var patreon = Login("patreon");
|
||||
PutSelf("discord", Login("discord"));
|
||||
PutSelf("twitch", Login("twitch"));
|
||||
PutOther("discord", discord, patreon);
|
||||
_clock.Advance(TimeSpan.FromSeconds(200));
|
||||
PutOther("patreon", discord, patreon);
|
||||
_clock.Advance(TimeSpan.FromSeconds(100)); // the discord proof is 300 s old, the patreon one 100 s
|
||||
|
||||
var self = PutSelf("discord", Login("discord")); // keeps the self side alive
|
||||
var self = PutSelf("twitch", Login("twitch")); // keeps the self side alive
|
||||
var unproved = Assert.IsType<PeekResult.LoginsUnproved>(_proofs.Peek(_actor, MergeSide.Self));
|
||||
|
||||
Assert.Equal(PutOutcome.Stored, self);
|
||||
|
|
@ -213,4 +213,17 @@ public sealed class MergeProofsTests
|
|||
|
||||
Assert.Equal(0, HeldProofs());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_sides_holding_a_login_of_one_provider_are_a_conflict_named_by_provider_only_in_both_directions()
|
||||
{
|
||||
PutSelf("discord", Login("discord"), Login("twitch"));
|
||||
PutOther("patreon", Login("patreon"), Login("discord"), Login("twitch"));
|
||||
|
||||
foreach (var survivor in new[] { MergeSide.Self, MergeSide.Other })
|
||||
{
|
||||
var conflict = Assert.IsType<PeekResult.ProviderConflict>(_proofs.Peek(_actor, survivor));
|
||||
Assert.Equal(["discord", "twitch"], conflict.Providers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue