Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ public static IEndpointRouteBuilder MapPluginBundles(this IEndpointRouteBuilder
Caller(http),
ct));

MapPrebuilt(group);
MapPublish(endpoints);
return endpoints;
}
Expand All @@ -153,6 +154,118 @@ public static IEndpointRouteBuilder MapPluginBundles(this IEndpointRouteBuilder
/// NOT the instance-key group above — a read grant says which packages an instance may PULL,
/// while publishing writes bytes every consumer will then load.</para>
/// </summary>
// ─────────────────── prebuilt: the registry SERVES sealed publications ───────────────────
//
// Step 2 of the plugin build contract (Doc/Architecture/PluginBuildContract): a downstream
// repo INSTALLS its upstream from the upstream's sealed publication. Until this route existed
// the only reader of `prebuilt-bundles/<identity>/<source>/` was an Azure OIDC identity whose
// federated credentials live in the Entra tenant — none of them for `pull_request`, so a gate
// could not fetch on the one event it exists for (AADSTS700213, measured 2026-08-27). The
// portal already mounts the share (PreWarm:PrebuiltBundleRoot); serving it here puts the read
// behind the SAME authenticator and grant model as every other bundle route, so who may fetch
// which source is a `PluginGrantEntry` on a mesh node — auditable, listable, revocable — and
// never a rule in a cloud tenant nobody can query.
//
// Authority: a whole-source grant (`<source>/*`) on the caller. The identity that PUBLISHES a
// source is the one that may fetch what it depends on, scoped per source — a satellite holds
// `plugins/*` to fetch, never a grant to publish AS plugins.
//
// 🚨 Serves ONLY what the `_complete` seal lists (PublishedBundleCatalogue.SealedBundlesOf):
// a torn publication — no sentinel, or a listed bundle absent — is refused wholesale, exactly
// as the boot seeder refuses it. A consumer must never be handed a set the portal itself
// would decline.
private const string PrebuiltSegment = "prebuilt";

private static void MapPrebuilt(RouteGroupBuilder group)
{
group.MapGet($"/{PrebuiltSegment}/{{identity}}/{{source}}",
(HttpContext http, string identity, string source) =>
PrebuiltIndex(http, identity, source, Caller(http)));
group.MapGet($"/{PrebuiltSegment}/{{identity}}/{{source}}/{{bundle}}",
(HttpContext http, string identity, string source, string bundle) =>
PrebuiltBundle(http, identity, source, bundle, Caller(http)));
}

/// <summary>The sealed publication's bundle names, or 404 when none is sealed for that
/// identity/source, or 403 when the caller holds no whole-source grant on it.</summary>
private static IResult PrebuiltIndex(
HttpContext http, string identity, string source, AuthenticatedInstance? caller)
{
if (PrebuiltDecision(http, identity, source, caller) is { } refused)
return refused;
var directory = PrebuiltDirectory(http, identity, source);
var sealed_ = directory is null ? null : PublishedBundleCatalogue.SealedBundlesOf(directory, Log(http));
if (sealed_ is null)
return Results.Json(
new { error = $"no sealed publication for source '{source}' under framework identity '{identity}'" },
statusCode: StatusCodes.Status404NotFound);
return Results.Json(new { identity, source, bundles = sealed_ });
}

/// <summary>One sealed bundle's bytes. A name the seal does not list is 404 even if the file
/// exists — an unsealed file is not part of the publication.</summary>
private static IResult PrebuiltBundle(
HttpContext http, string identity, string source, string bundle, AuthenticatedInstance? caller)
{
if (PrebuiltDecision(http, identity, source, caller) is { } refused)
return refused;
var directory = PrebuiltDirectory(http, identity, source);
var sealed_ = directory is null ? null : PublishedBundleCatalogue.SealedBundlesOf(directory, Log(http));
if (sealed_ is null || !sealed_.Contains(bundle, StringComparer.OrdinalIgnoreCase))
return NoSuchBundle();
var path = Path.Combine(directory!, bundle);
return Results.File(path, "application/zip", fileDownloadName: bundle);
Comment on lines +210 to +217
}

/// <summary>
/// The three refusals, in the order that leaks least: no caller (401, the group filter's
/// message), a path segment that is not a bare name (400 — `..` or a separator would walk
/// the share), a caller without a whole-source grant (403). Records the decision on the
/// ledger like every other bundle route, so a refused fetch is as visible as a refused serve.
/// </summary>
private static IResult? PrebuiltDecision(
HttpContext http, string identity, string source, AuthenticatedInstance? caller)
{
if (caller is null)
return Results.Json(new { error = "a registered instance key is required" },
statusCode: StatusCodes.Status401Unauthorized);
if (!IsBareName(identity) || !IsBareName(source))
return Results.Json(new { error = "identity and source must be bare names" },
statusCode: StatusCodes.Status400BadRequest);
var allowed = caller.Grant.Allows(source, PluginGrantEntry.AllPackages);
Ledger(http)?.Record(new EntitlementDecision(
$"{PrebuiltSegment}/{source}",
allowed ? EntitlementOutcome.Granted : EntitlementOutcome.Denied,
EntitlementAnchorKind.Registry,
source,
true,
allowed
? $"whole-source grant '{source}/*' held by {caller.Instance.InstanceId}"
: $"{caller.Instance.InstanceId} holds no '{source}/*' grant — a fetch needs the whole source"));
return allowed
? null
: Results.Json(
new { error = $"instance '{caller.Instance.InstanceId}' is not granted source '{source}'" },
statusCode: StatusCodes.Status403Forbidden);
}

private static string? PrebuiltDirectory(HttpContext http, string identity, string source)
{
var root = http.RequestServices.GetService<IConfiguration>()?[PublishedBundleCatalogue.PublishedRootConfigKey];
if (string.IsNullOrWhiteSpace(root))
return null;
var directory = Path.Combine(root, identity, source);
return Directory.Exists(directory) ? directory : null;
}

private static bool IsBareName(string segment) =>
segment.Length > 0
&& segment.IndexOfAny(['/', '\\', ':']) < 0
&& segment != "." && segment != "..";

private static ILogger? Log(HttpContext http) =>
http.RequestServices.GetService<ILoggerFactory>()?.CreateLogger(typeof(PluginBundleEndpoints));

private static void MapPublish(IEndpointRouteBuilder endpoints)
{
var config = endpoints.ServiceProvider.GetService<IConfiguration>();
Expand Down
14 changes: 14 additions & 0 deletions src/MeshWeaver.PluginCatalog/PublishedBundleCatalogue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public static class PublishedBundleCatalogue
/// </summary>
public const string ReleaseMarkerDirectoryName = "_releases";

/// <summary>The configuration key naming the published bundle root — forwarded from
/// <see cref="ShippedPrebuiltBundles.PublishedRootConfigKey"/> so a consumer in the portal
/// layer can address the same directory without referencing the hosting assembly.</summary>
public const string PublishedRootConfigKey = ShippedPrebuiltBundles.PublishedRootConfigKey;

/// <summary>
/// Reads the catalogue for one target release. Synchronous and total — every failure becomes a
/// <see cref="ReleaseArtifacts.Unreadable"/> observation rather than an exception, because the
Expand Down Expand Up @@ -159,6 +164,15 @@ private static IEnumerable<string> SealedBundleNames(string identityDirectory, I
/// directory, and the one that said "ready" would be the optimistic one — which is precisely
/// the direction a gate must never be wrong in.</para>
/// </summary>
/// <summary>
/// The bundle names one source's SEALED publication lists, or null when the publication is
/// torn (no sentinel, or a listed bundle absent). This is THE rule for "what may be served
/// from this directory" — the registry's prebuilt route reads through it so a consumer can
/// never be handed a torn set the boot seeder itself would refuse.
Comment on lines +167 to +171
/// </summary>
public static IReadOnlyList<string>? SealedBundlesOf(string sourceDirectory, ILogger? logger = null)
=> CompleteBundlesOf(sourceDirectory, logger);

private static IReadOnlyList<string>? CompleteBundlesOf(string sourceDirectory, ILogger? logger)
{
var sentinel = Path.Combine(
Expand Down
142 changes: 142 additions & 0 deletions test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.IO.Compression;
using System.Net;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using Memex.Portal.Shared.Api;
using Memex.Portal.Shared.Authentication;
using MeshWeaver.Hosting.Monolith.TestBase;
using MeshWeaver.Hosting;
using MeshWeaver.Messaging;
using MeshWeaver.PluginCatalog;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Xunit;

namespace Memex.Portal.Shared.Test;

/// <summary>
/// The registry SERVES sealed publications — step 2 of the plugin build contract, the half that
/// lets a downstream repo INSTALL its upstream without an Azure identity, a checkout of the
/// upstream's source, or any secret beyond the instance key it already holds.
///
/// <para>Pins four things, each of which was a real gap on 2026-08-27:</para>
/// <list type="number">
/// <item>a caller with a whole-source grant gets the seal's list and each listed bundle;</item>
/// <item>a caller WITHOUT the grant is refused (403) — the fetch is scoped per source, so a
/// satellite that may fetch <c>plugins</c> cannot fetch a source it does not depend on;</item>
/// <item>an unsealed source directory is 404 — the route serves nothing the boot seeder would
/// itself refuse, so a consumer can never be handed a torn set;</item>
/// <item>a bundle present on disk but NOT listed by the seal is 404 — an unsealed file is not
/// part of the publication, whatever the filesystem says.</item>
/// </list>
/// </summary>
public class PrebuiltPublicationTest(ITestOutputHelper output) : MonolithMeshTestBase(output)
{
private const string Identity = "s0123456789abcdef0123456789abcdef";
private const string Source = "plugins";
private const string Granted = "granted-satellite";
private const string Ungranted = "ungranted-satellite";

[Fact(Timeout = 120_000)]
public async Task ServesTheSealedSet_ToAWholeSourceGrant_AndRefusesEveryoneElse()
{
var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-" + Guid.NewGuid().ToString("N"));
var dir = Path.Combine(root, Identity, Source);
Directory.CreateDirectory(dir);
try
{
WriteBundle(Path.Combine(dir, "Store.zip"), "Store");
WriteBundle(Path.Combine(dir, "Edu.zip"), "Edu");
WriteBundle(Path.Combine(dir, "Orphan.zip"), "Orphan"); // on disk, NOT sealed
File.WriteAllText(Path.Combine(dir, ShippedPrebuiltBundles.CompletionSentinelFileName), "Store.zip\nEdu.zip\n");
var torn = Path.Combine(root, Identity, "torn"); // a directory with no seal
Directory.CreateDirectory(torn);
WriteBundle(Path.Combine(torn, "X.zip"), "X");

var grantedKey = await RegisterInstance(Granted, $"{Source}/*");
var ungrantedKey = await RegisterInstance(Ungranted);
await using var app = await StartHost(root);

// 1. the seal's list, exactly — the orphan is not in it
var index = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}", grantedKey);
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
var body = await index.Content.ReadAsStringAsync();
Assert.Contains("Store.zip", body); Assert.Contains("Edu.zip", body);
Assert.DoesNotContain("Orphan.zip", body);

// …and each listed bundle's bytes
var store = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/Store.zip", grantedKey);
Assert.Equal(HttpStatusCode.OK, store.StatusCode);
Assert.True((await store.Content.ReadAsByteArrayAsync()).Length > 0);

// 2. no grant on the source → 403, never the bytes
var refused = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}", ungrantedKey);
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);

// 3. unsealed directory → 404, even to a caller that would otherwise be allowed
var tornKey = await RegisterInstance("torn-reader", "torn/*");
var tornResp = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/torn", tornKey);
Assert.Equal(HttpStatusCode.NotFound, tornResp.StatusCode);

// 4. present on disk but not sealed → 404
var orphan = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/Orphan.zip", grantedKey);
Assert.Equal(HttpStatusCode.NotFound, orphan.StatusCode);

// and a path segment that is not a bare name is refused before any disk read
var walk = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/..%2F{Source}", grantedKey);
Assert.NotEqual(HttpStatusCode.OK, walk.StatusCode);
Comment on lines +88 to +90
}
finally
{
try { Directory.Delete(root, recursive: true); } catch { /* best effort */ }
}
}

private static void WriteBundle(string path, string plugin)
{
using var zip = ZipFile.Open(path, ZipArchiveMode.Create);
using var w = new StreamWriter(zip.CreateEntry("meshweaver/manifest.json").Open());
w.Write($"{{\"plugin\":\"{plugin}\"}}");
}

private MeshWeaverInstanceService InstanceService(params string[] defaultGrants) =>
new(Mesh.ServiceProvider.GetRequiredService<MeshWeaver.Mesh.Services.IMeshService>(),
Mesh,
Mesh.ServiceProvider.GetRequiredService<ILogger<MeshWeaverInstanceService>>(),
new ConfigurationBuilder()
.AddInMemoryCollection(defaultGrants.Select((entry, i) =>
new KeyValuePair<string, string?>($"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry)))
.Build());

private Task<string> RegisterInstance(string instanceId, params string[] defaultGrants) =>
InstanceService(defaultGrants)
.Register("owner", "Owner", "owner@test.com", instanceId, instanceId)
.Select(r => r.RawKey).FirstAsync().Timeout(TimeSpan.FromSeconds(60)).ToTask();

private async Task<WebApplication> StartHost(string publishedRoot)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[PublishedBundleCatalogue.PublishedRootConfigKey] = publishedRoot,
});
builder.Services.AddSingleton<IMessageHub>(Mesh);
builder.Services.AddSingleton(new InstanceRegistryAuthenticator(
Mesh, Mesh.ServiceProvider.GetRequiredService<ILogger<InstanceRegistryAuthenticator>>()));
var app = builder.Build();
app.MapPluginBundles();
await app.StartAsync();
return app;
}

private static async Task<HttpResponseMessage> Get(WebApplication app, string route, string key)
{
using var request = new HttpRequestMessage(HttpMethod.Get, route);
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}");
return await app.GetTestClient().SendAsync(request);
}
}
Loading