From 19222bd1669fb24945b6defae2a47c8fb21c3e68 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 22 Jun 2026 15:23:30 +0100 Subject: [PATCH] Bind bridge mouths to Heimdall-linked identities --- .../Features/Bridge/BridgeActionService.cs | 58 ++++++++++++++++--- .../PlatformEndpointsTests.cs | 49 +++++++++++++++- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs index 7f3e615..ea04bb0 100644 --- a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs +++ b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs @@ -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 EvaluateRegisteredBifrostIdentityAsync( + private async Task 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, diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index f74c3e0..51b17ee 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -802,7 +802,7 @@ public sealed class PlatformEndpointsTests : IClassFixture(); + Assert.NotNull(action); + Assert.Equal("Denied", action.Status); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + 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(); @@ -1416,6 +1458,7 @@ public sealed class PlatformEndpointsTests : IClassFixture