Desk: the training-notice receipt gates the chat token only; Bifrost no longer masks answerer ids (Huginn's door withholds them), notes stay stripped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c7edcd4f58
commit
17e46df690
4 changed files with 68 additions and 33 deletions
|
|
@ -1,25 +1,14 @@
|
|||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else. For them
|
||||
// every note is nil and every answerer is the sentinel; every key stays written (nil where absent, a string where a
|
||||
// string is required), so the redacted body is still the contract's shape. Cards, outcomes, codes, counts and the
|
||||
// number of answers are untouched; Huginn returns the full record and Bifrost drops and adds nothing.
|
||||
//
|
||||
// The sentinel is one fixed string for every answer: nothing derived from an account id (a hash or a per-reader
|
||||
// alias would still let a reader link two answers to one person, or confirm a guess), and it is not a D-format
|
||||
// UUID, so it cannot be mistaken for an account. The contract's `answerer` is a non-empty string and nothing more.
|
||||
// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else: every note is
|
||||
// nil, with every key still written, so the body is still the contract's shape. Account ids are not Bifrost's to hide:
|
||||
// Huginn's door withholds the decider ids and answerer ids from a non-decider and returns counts
|
||||
// (eureka-substrate:ruling:desk-read-visibility-withheld-ids), and Bifrost cannot mask what it cannot read (the card is
|
||||
// opaque to it). Cards, outcomes, codes, counts and the number of answers are untouched.
|
||||
public static class DeskRedaction
|
||||
{
|
||||
public const string MaskedAnswerer = "masked";
|
||||
|
||||
public static ForkEntry ForNonDecider(ForkEntry entry) =>
|
||||
entry with
|
||||
{
|
||||
Record = entry.Record with
|
||||
{
|
||||
Answers = [.. entry.Record.Answers.Select(a => a with { Note = null, Answerer = MaskedAnswerer })],
|
||||
},
|
||||
};
|
||||
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
|
||||
|
||||
public static ForkList ForNonDecider(ForkList list) => list with { Forks = [.. list.Forks.Select(ForNonDecider)] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,10 +101,11 @@ public static class OidcEndpoints
|
|||
_ => [Destinations.AccessToken]
|
||||
});
|
||||
|
||||
// The training notice is the chat's: a desk token is not a chat token and needs no receipt (Meta's ruling, 2026-11-11).
|
||||
// Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree.
|
||||
// Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request
|
||||
// (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it.
|
||||
if (!await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
|
||||
if (!IsDeskToken(principal) && !await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted))
|
||||
{
|
||||
return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString));
|
||||
}
|
||||
|
|
@ -112,6 +113,9 @@ public static class OidcEndpoints
|
|||
return Results.SignIn(principal, properties: null, OpenIddictScheme);
|
||||
}
|
||||
|
||||
/// <summary>A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt.</summary>
|
||||
private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes().Any(DeskEndpoints.IsDeskScope);
|
||||
|
||||
private static async Task<IResult> ExchangeAsync(HttpContext http, BifrostDbContext db)
|
||||
{
|
||||
// Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
|
||||
|
|
@ -119,7 +123,7 @@ public static class OidcEndpoints
|
|||
// because the code outlives the authorize decision (an account suspended or withdrawn since gets no tokens).
|
||||
var result = await http.AuthenticateAsync(OpenIddictScheme);
|
||||
var subject = result.Principal?.GetClaim(Claims.Subject);
|
||||
if (await ChatRefusalAsync(db, subject, http.RequestAborted) is { } refusal)
|
||||
if (await ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
|
||||
{
|
||||
return Deny(Errors.InvalidGrant, refusal);
|
||||
}
|
||||
|
|
@ -136,7 +140,7 @@ public static class OidcEndpoints
|
|||
}
|
||||
|
||||
// An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered.
|
||||
if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted) is { } refusal)
|
||||
if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal)
|
||||
{
|
||||
return Deny(Errors.InsufficientAccess, refusal);
|
||||
}
|
||||
|
|
@ -154,9 +158,9 @@ public static class OidcEndpoints
|
|||
/// <summary>
|
||||
/// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler:
|
||||
/// null when they may, else the description of why not. The account exists, its membership is not Suspended (absent, pending
|
||||
/// or Active all chat), and it holds a live receipt for the notice version in force. Re-read on every call.
|
||||
/// or Active all chat), and, for a chat token (not a desk token), it holds a live receipt for the notice version in force. Re-read on every call.
|
||||
/// </summary>
|
||||
internal static async Task<string?> ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
|
||||
internal static async Task<string?> ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken, bool deskToken = false)
|
||||
{
|
||||
if (!Guid.TryParse(subject, out var accountId))
|
||||
{
|
||||
|
|
@ -171,7 +175,7 @@ public static class OidcEndpoints
|
|||
return SuspendedDescription;
|
||||
}
|
||||
|
||||
return await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription;
|
||||
return deskToken || await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription;
|
||||
}
|
||||
|
||||
/// <summary>The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call.</summary>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Bifrost.Web.Data;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
|
|
@ -118,6 +119,32 @@ public sealed class DeskCredentialTests : IDisposable
|
|||
Assert.Equal("answer", Assert.Single(factory.Door.Calls).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_desk_token_issues_for_a_member_with_no_training_notice_receipt_and_a_chat_token_does_not()
|
||||
{
|
||||
var (client, identity) = await SignedInAsync();
|
||||
await RemoveReceiptsAsync(identity, withdrawOnly: false);
|
||||
|
||||
// Chat: sent to the consent page, no code.
|
||||
var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile");
|
||||
var chatFinal = await DriveAsync(client, chat);
|
||||
Assert.Equal(HttpStatusCode.Redirect, chatFinal.StatusCode);
|
||||
Assert.DoesNotContain("code=", chatFinal.Headers.Location!.OriginalString);
|
||||
|
||||
// Desk: a code, a token, and the desk answers it.
|
||||
var request = NewRequest(DeskClient, DeskRedirect, "desk.read");
|
||||
var code = await CodeAsync(client, request);
|
||||
var token = await ExchangeAsync(client, request, code, DeskClient, DeskSecret);
|
||||
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null));
|
||||
Assert.Equal(200, (await GetAsync(token)).Status);
|
||||
|
||||
// A desk code survives the receipt going between authorize and the exchange; a suspended account still does not.
|
||||
var later = NewRequest(DeskClient, DeskRedirect, "desk.read");
|
||||
var laterCode = await CodeAsync(client, later);
|
||||
await RemoveReceiptsAsync(identity, withdrawOnly: true);
|
||||
Assert.False(string.IsNullOrEmpty(await ExchangeAsync(client, later, laterCode, DeskClient, DeskSecret)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk()
|
||||
{
|
||||
|
|
@ -199,7 +226,7 @@ public sealed class DeskCredentialTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_member_who_is_not_a_decider_reads_masked_answerers_through_a_real_token()
|
||||
public async Task A_member_who_is_not_a_decider_reads_no_notes_through_a_real_token_and_ids_pass_as_the_door_sent_them()
|
||||
{
|
||||
var (_, token) = await MemberWithTokenAsync("desk.read");
|
||||
const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it
|
||||
|
|
@ -213,7 +240,7 @@ public sealed class DeskCredentialTests : IDisposable
|
|||
var reply = await GetAsync(token);
|
||||
|
||||
Assert.Equal(200, reply.Status);
|
||||
Assert.DoesNotContain(secretAnswerer, reply.Text);
|
||||
Assert.Contains(secretAnswerer, reply.Text); // Bifrost masks no id; Huginn's door withholds them from non-deciders
|
||||
Assert.DoesNotContain("private words", reply.Text);
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +273,25 @@ public sealed class DeskCredentialTests : IDisposable
|
|||
return (client, identity);
|
||||
}
|
||||
|
||||
// Leaves the account with no live training-notice receipt: none at all, or each one withdrawn.
|
||||
private async Task RemoveReceiptsAsync(string identity, bool withdrawOnly)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id;
|
||||
var receipts = db.ConsentReceipts.Where(x => x.UserAccountId == id).ToList();
|
||||
if (withdrawOnly)
|
||||
{
|
||||
receipts.ForEach(r => r.WithdrawnAtUtc = DateTimeOffset.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.ConsentReceipts.RemoveRange(receipts);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<(string Identity, string Token)> MemberWithTokenAsync(string scope)
|
||||
{
|
||||
var (client, identity) = await SignedInAsync();
|
||||
|
|
|
|||
|
|
@ -430,17 +430,13 @@ public sealed class DeskReadTests
|
|||
Assert.DoesNotContain("decider's own words", list);
|
||||
Assert.False(AnyNote(JsonNode.Parse(read)));
|
||||
Assert.False(AnyNote(JsonNode.Parse(list)));
|
||||
// No answerer id of any answer reaches a non-decider, and every answer says the same thing instead.
|
||||
var readMasked = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]);
|
||||
Assert.Equal(4, readMasked.Count());
|
||||
Assert.All(readMasked, answerer => Assert.Equal("masked", answerer));
|
||||
var masked = JsonNode.Parse(list)!["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray()).Select(a => (string?)a!["answerer"]);
|
||||
Assert.Equal(8, masked.Count());
|
||||
Assert.All(masked, answerer => Assert.Equal("masked", answerer));
|
||||
// Bifrost masks no id: what the door sent for the answerers is what the reader gets (the door withholds ids from non-deciders).
|
||||
var readAnswerers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]);
|
||||
Assert.Equal(NotedEntry().Record.Answers.Select(x => x.Answerer), readAnswerers);
|
||||
// Outcomes and cards stay; the key stays written as nil, so the body is still the contract's shape.
|
||||
var expected = JsonSerializer.SerializeToNode(NotedEntry() with
|
||||
{
|
||||
Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null, Answerer = DeskRedaction.MaskedAnswerer })] },
|
||||
Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null })] },
|
||||
}, DeskJson.Options);
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected));
|
||||
var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray();
|
||||
|
|
|
|||
Loading…
Reference in a new issue