diff --git a/memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs b/memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs index c8dcbb7fbd..1ce8fc9835 100644 --- a/memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs +++ b/memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs @@ -132,6 +132,7 @@ public static IEndpointRouteBuilder MapPluginBundles(this IEndpointRouteBuilder Caller(http), ct)); + MapPrebuilt(group); MapPublish(endpoints); return endpoints; } @@ -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. /// + // ─────────────────── 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///` 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 (`/*`) 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))); + } + + /// 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. + 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_ }); + } + + /// 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. + 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); + } + + /// + /// 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. + /// + 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()?[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()?.CreateLogger(typeof(PluginBundleEndpoints)); + private static void MapPublish(IEndpointRouteBuilder endpoints) { var config = endpoints.ServiceProvider.GetService(); diff --git a/src/MeshWeaver.PluginCatalog/PublishedBundleCatalogue.cs b/src/MeshWeaver.PluginCatalog/PublishedBundleCatalogue.cs index b73e19d205..571a94cf7d 100644 --- a/src/MeshWeaver.PluginCatalog/PublishedBundleCatalogue.cs +++ b/src/MeshWeaver.PluginCatalog/PublishedBundleCatalogue.cs @@ -43,6 +43,11 @@ public static class PublishedBundleCatalogue /// public const string ReleaseMarkerDirectoryName = "_releases"; + /// The configuration key naming the published bundle root — forwarded from + /// so a consumer in the portal + /// layer can address the same directory without referencing the hosting assembly. + public const string PublishedRootConfigKey = ShippedPrebuiltBundles.PublishedRootConfigKey; + /// /// Reads the catalogue for one target release. Synchronous and total — every failure becomes a /// observation rather than an exception, because the @@ -159,6 +164,15 @@ private static IEnumerable 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. /// + /// + /// 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. + /// + public static IReadOnlyList? SealedBundlesOf(string sourceDirectory, ILogger? logger = null) + => CompleteBundlesOf(sourceDirectory, logger); + private static IReadOnlyList? CompleteBundlesOf(string sourceDirectory, ILogger? logger) { var sentinel = Path.Combine( diff --git a/test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs b/test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs new file mode 100644 index 0000000000..543c18b283 --- /dev/null +++ b/test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs @@ -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; + +/// +/// 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. +/// +/// Pins four things, each of which was a real gap on 2026-08-27: +/// +/// a caller with a whole-source grant gets the seal's list and each listed bundle; +/// a caller WITHOUT the grant is refused (403) — the fetch is scoped per source, so a +/// satellite that may fetch plugins cannot fetch a source it does not depend on; +/// 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; +/// 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. +/// +/// +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); + } + 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(), + Mesh, + Mesh.ServiceProvider.GetRequiredService>(), + new ConfigurationBuilder() + .AddInMemoryCollection(defaultGrants.Select((entry, i) => + new KeyValuePair($"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) + .Build()); + + private Task 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 StartHost(string publishedRoot) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [PublishedBundleCatalogue.PublishedRootConfigKey] = publishedRoot, + }); + builder.Services.AddSingleton(Mesh); + builder.Services.AddSingleton(new InstanceRegistryAuthenticator( + Mesh, Mesh.ServiceProvider.GetRequiredService>())); + var app = builder.Build(); + app.MapPluginBundles(); + await app.StartAsync(); + return app; + } + + private static async Task 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); + } +}