Gate future public bridge surfaces
This commit is contained in:
parent
19222bd166
commit
675c475752
3 changed files with 103 additions and 14 deletions
|
|
@ -60,13 +60,14 @@ The hosted app now exposes a first-class bridge action ledger for governed trans
|
|||
- `POST /bridge/actions/{id}/fail`
|
||||
|
||||
The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt.
|
||||
Persona and agent actions that cross into Discord or Reddit must carry a Bifrost
|
||||
identity plus a Heimdall-backed capability/account reference at request time.
|
||||
The local bridge token only proves that the actuator may call the ledger; it is
|
||||
not the actor identity and not outside-provider authority. The ledger stores the
|
||||
Bifrost identity, Heimdall reference, Epiphany run id, lane id, and agent
|
||||
identity beside the action so later receipts can answer which agent did what
|
||||
without opening local transcripts or trusting helper-script folklore.
|
||||
Persona and agent actions that cross into Discord, Reddit, or a future
|
||||
outside-world surface (`Other` until the surface has a first-class enum value)
|
||||
must carry a Bifrost identity plus a Heimdall-backed capability/account reference
|
||||
at request time. The local bridge token only proves that the actuator may call
|
||||
the ledger; it is not the actor identity and not outside-provider authority. The
|
||||
ledger stores the Bifrost identity, Heimdall reference, Epiphany run id, lane id,
|
||||
and agent identity beside the action so later receipts can answer which agent
|
||||
did what without opening local transcripts or trusting helper-script folklore.
|
||||
|
||||
The hosted app also exposes a dispatch-run ledger for the older Bifrost-launched worker bridge:
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ Current gate behavior:
|
|||
- agent, Persona, and service actions must cite provenance: `authorityReference`, `sourceKind` plus `sourceId`, `workItemId`, or `motionId`
|
||||
- completed actions must report a receipt URL, external receipt id, or serialized receipt payload
|
||||
- GitHub mutation commands fail closed unless Bifrost authorization is configured; `--allow-ungated-github true` is the explicit operator-recovery hatch
|
||||
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability token or verifiable capability/account reference. The local bridge token is a transitional service actuator credential, not a substitute for Bifrost identity or Heimdall-backed outside-account authority.
|
||||
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability token or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate, using `Other` only as a temporary enum bucket until the target surface gets a named contract. The local bridge token is a transitional service actuator credential, not a substitute for Bifrost identity or Heimdall-backed outside-account authority.
|
||||
|
||||
Current CLI wiring:
|
||||
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ public sealed class BridgeActionService(
|
|||
{
|
||||
if (string.IsNullOrWhiteSpace(request.BifrostIdentity))
|
||||
{
|
||||
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Bifrost identity.");
|
||||
return BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a Bifrost identity.");
|
||||
}
|
||||
|
||||
var identityResult = await ResolveRegisteredBifrostIdentityAsync(request.BifrostIdentity, cancellationToken);
|
||||
|
|
@ -221,7 +221,7 @@ public sealed class BridgeActionService(
|
|||
|
||||
if (string.IsNullOrWhiteSpace(request.HeimdallCapabilityReference))
|
||||
{
|
||||
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Heimdall-backed capability or account reference.");
|
||||
return BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a Heimdall-backed capability or account reference.");
|
||||
}
|
||||
|
||||
var capabilityDecision = EvaluateHeimdallCapabilityReference(request, identityResult.UserAccount!);
|
||||
|
|
@ -291,7 +291,7 @@ public sealed class BridgeActionService(
|
|||
{
|
||||
return new RegisteredBifrostIdentityResult(
|
||||
null,
|
||||
BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a registered Bifrost identity."));
|
||||
BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a registered Bifrost identity."));
|
||||
}
|
||||
|
||||
return new RegisteredBifrostIdentityResult(userAccount, null);
|
||||
|
|
@ -303,13 +303,13 @@ public sealed class BridgeActionService(
|
|||
{
|
||||
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.");
|
||||
return BridgePolicyDecision.Reject("Persona and agent outside-world 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.");
|
||||
return BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a Heimdall-backed capability or account reference.");
|
||||
}
|
||||
|
||||
var expectedSurface = request.TargetSurface switch
|
||||
|
|
@ -440,7 +440,7 @@ public sealed class BridgeActionService(
|
|||
|
||||
private static bool RequiresExternalAccountCapability(BridgeActionRequest request) =>
|
||||
request.ActorKind is BridgeActorKind.Agent or BridgeActorKind.Persona &&
|
||||
request.TargetSurface is BridgeTargetSurface.Discord or BridgeTargetSurface.Reddit;
|
||||
request.TargetSurface is BridgeTargetSurface.Discord or BridgeTargetSurface.Reddit or BridgeTargetSurface.Other;
|
||||
|
||||
private static string NormalizeRepository(string value) => NormalizeText(value).ToLowerInvariant();
|
||||
|
||||
|
|
|
|||
|
|
@ -969,6 +969,94 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
Assert.Equal("epiphany.Persona", savedAction.EpiphanyAgentIdentity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Persona_other_surface_action_without_bifrost_identity_is_denied()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var requestPayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
actorKind = "Persona",
|
||||
actorName = "Epiphany Persona",
|
||||
targetSurface = "Other",
|
||||
actionKind = "Other",
|
||||
targetLocator = "future://public-surface/channel",
|
||||
sourceKind = "epiphany_persona_public_surface",
|
||||
sourceId = "persona-speech-audit-future-surface-missing-identity",
|
||||
authorityReference = "epiphany.persona_speech_audit",
|
||||
title = "Persona future public surface post",
|
||||
summary = "This future outside-world crossing must not pass without identity provenance."
|
||||
});
|
||||
|
||||
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("outside-world", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Bifrost identity", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Persona_other_surface_action_with_bifrost_identity_and_heimdall_reference_is_authorized()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona");
|
||||
|
||||
var requestPayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
actorKind = "Persona",
|
||||
actorName = "Epiphany Persona",
|
||||
targetSurface = "Other",
|
||||
actionKind = "Other",
|
||||
targetLocator = "future://public-surface/channel",
|
||||
sourceKind = "epiphany_persona_public_surface",
|
||||
sourceId = "persona-speech-audit-future-surface-authorized",
|
||||
authorityReference = "epiphany.persona_speech_audit",
|
||||
bifrostIdentity = "epiphany.Persona",
|
||||
heimdallCapabilityReference = "heimdall:future-surface:capability:epiphany-persona",
|
||||
epiphanyRunId = "epiphany-run-future-surface",
|
||||
epiphanyLaneId = "Persona",
|
||||
epiphanyAgentIdentity = "epiphany.Persona",
|
||||
title = "Persona future public surface post",
|
||||
summary = "This future outside-world crossing is allowed only with Bifrost and Heimdall provenance."
|
||||
});
|
||||
|
||||
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.Accepted, response.StatusCode);
|
||||
var action = await response.Content.ReadFromJsonAsync<BridgeActionHttpResult>();
|
||||
Assert.NotNull(action);
|
||||
Assert.Equal("Authorized", 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.Authorized, savedAction.Status);
|
||||
Assert.Equal(BridgeTargetSurface.Other, savedAction.TargetSurface);
|
||||
Assert.Equal("epiphany.Persona", savedAction.BifrostIdentity);
|
||||
Assert.Equal("heimdall:future-surface:capability:epiphany-persona", savedAction.HeimdallCapabilityReference);
|
||||
Assert.Equal("epiphany-run-future-surface", savedAction.EpiphanyRunId);
|
||||
Assert.Equal("Persona", savedAction.EpiphanyLaneId);
|
||||
Assert.Equal("epiphany.Persona", savedAction.EpiphanyAgentIdentity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Local_bridge_token_can_record_dispatch_run_lifecycle()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue