Bind bridge mouths to Heimdall-linked identities
This commit is contained in:
parent
80a5392935
commit
19222bd166
2 changed files with 96 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue