Bind bridge mouths to Heimdall-linked identities

This commit is contained in:
Meta Vens 2026-06-22 15:23:30 +01:00
commit 19222bd166
2 changed files with 96 additions and 11 deletions

View file

@ -213,16 +213,22 @@ public sealed class BridgeActionService(
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Bifrost identity.");
}
var identityDecision = await EvaluateRegisteredBifrostIdentityAsync(request.BifrostIdentity, cancellationToken);
if (identityDecision is not null)
var identityResult = await ResolveRegisteredBifrostIdentityAsync(request.BifrostIdentity, cancellationToken);
if (identityResult.Decision is not null)
{
return identityDecision;
return identityResult.Decision;
}
if (string.IsNullOrWhiteSpace(request.HeimdallCapabilityReference))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Heimdall-backed capability or account reference.");
}
var capabilityDecision = EvaluateHeimdallCapabilityReference(request, identityResult.UserAccount!);
if (capabilityDecision is not null)
{
return capabilityDecision;
}
}
if (request.WorkItemId is not null)
@ -264,7 +270,7 @@ public sealed class BridgeActionService(
: "Authorized for an active Bifrost member through Bifrost policy.");
}
private async Task<BridgePolicyDecision?> EvaluateRegisteredBifrostIdentityAsync(
private async Task<RegisteredBifrostIdentityResult> ResolveRegisteredBifrostIdentityAsync(
string bifrostIdentity,
CancellationToken cancellationToken)
{
@ -275,15 +281,47 @@ public sealed class BridgeActionService(
}
catch (BifrostIdentityException exception)
{
return BridgePolicyDecision.Reject(exception.Message);
return new RegisteredBifrostIdentityResult(null, BridgePolicyDecision.Reject(exception.Message));
}
var exists = await dbContext.UserAccounts.AnyAsync(
var userAccount = await dbContext.UserAccounts.SingleOrDefaultAsync(
x => x.NormalizedBifrostIdentity == normalizedIdentity,
cancellationToken);
if (!exists)
if (userAccount is null)
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a registered Bifrost identity.");
return new RegisteredBifrostIdentityResult(
null,
BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a registered Bifrost identity."));
}
return new RegisteredBifrostIdentityResult(userAccount, null);
}
private static BridgePolicyDecision? EvaluateHeimdallCapabilityReference(
BridgeActionRequest request,
UserAccount userAccount)
{
if (string.IsNullOrWhiteSpace(userAccount.HeimdallAccountId))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions require the Bifrost identity to be linked to a Heimdall account.");
}
var capabilityReference = NormalizeText(request.HeimdallCapabilityReference);
if (!capabilityReference.StartsWith("heimdall:", StringComparison.OrdinalIgnoreCase))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Heimdall-backed capability or account reference.");
}
var expectedSurface = request.TargetSurface switch
{
BridgeTargetSurface.Discord => "discord",
BridgeTargetSurface.Reddit => "reddit",
_ => string.Empty
};
if (!string.IsNullOrWhiteSpace(expectedSurface) &&
!capabilityReference.Contains($":{expectedSurface}:", StringComparison.OrdinalIgnoreCase))
{
return BridgePolicyDecision.Reject("Heimdall capability reference does not match the requested bridge target surface.");
}
return null;
@ -435,6 +473,10 @@ public sealed record BridgeCaller(
(IsActiveMember && action.ActorUserAccountId == UserAccountId);
}
internal sealed record RegisteredBifrostIdentityResult(
UserAccount? UserAccount,
BridgePolicyDecision? Decision);
public sealed record BridgeActionRequest(
BridgeActorKind ActorKind,
string ActorName,

View file

@ -802,7 +802,7 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
public async Task Persona_discord_action_requires_heimdall_reference_after_bifrost_identity()
{
using var client = _factory.CreateClient();
await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona");
await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona");
var requestPayload = JsonSerializer.Serialize(new
{
@ -838,6 +838,48 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Contains("Heimdall", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Persona_discord_action_with_unlinked_heimdall_account_is_denied()
{
using var client = _factory.CreateClient();
var identity = $"native.{Guid.NewGuid():N}";
await EnsureBifrostIdentityRegisteredAsync(identity);
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Persona",
actorName = "Native Persona",
targetSurface = "Discord",
actionKind = "DiscordPost",
targetLocator = "channel/1501196543150264332",
sourceKind = "epiphany_persona_speech",
sourceId = "persona-speech-audit-unlinked-heimdall",
authorityReference = "epiphany.persona_speech_audit",
bifrostIdentity = identity,
heimdallCapabilityReference = "heimdall:discord:capability:native-persona",
title = "Persona Discord post",
summary = "This should not pass until the Bifrost identity is linked to Heimdall."
});
using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request");
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var action = await response.Content.ReadFromJsonAsync<BridgeActionHttpResult>();
Assert.NotNull(action);
Assert.Equal("Denied", action.Status);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id);
Assert.Equal(BridgeActionStatus.Denied, savedAction.Status);
Assert.Contains("linked to a Heimdall account", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Persona_discord_action_with_unregistered_bifrost_identity_is_denied()
{
@ -883,7 +925,7 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
public async Task Persona_reddit_action_with_bifrost_identity_and_heimdall_reference_is_authorized_and_stored()
{
using var client = _factory.CreateClient();
await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona");
await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona");
var requestPayload = JsonSerializer.Serialize(new
{
@ -1401,7 +1443,7 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
$"Expected governance receipt seed to succeed but got {(int)response.StatusCode}: {body}");
}
private async Task EnsureBifrostIdentityRegisteredAsync(string identity)
private async Task EnsureBifrostIdentityRegisteredAsync(string identity, string heimdallAccountId = "")
{
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
@ -1416,6 +1458,7 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
{
BifrostIdentity = normalizedIdentity,
NormalizedBifrostIdentity = normalizedIdentity,
HeimdallAccountId = heimdallAccountId,
DisplayName = normalizedIdentity,
CreatedAtUtc = now,
LastSeenAtUtc = now,