diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-compiling-a-type-now-asks-permission.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-compiling-a-type-now-asks-permission.md new file mode 100644 index 0000000000..f7f345317d --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-compiling-a-type-now-asks-permission.md @@ -0,0 +1,30 @@ +--- +Name: Compiling a type now asks permission +Category: Fix +Description: Anyone who could open a NodeType could also trigger a build of it, even with read-only access. Compiling now requires the Compile permission — the one editors already have and viewers do not. +Icon: ShieldTask +Order: -20260902 +--- + +# Compiling a type now asks permission + +Most operations on a node check what you are allowed to do with it before doing it. Recycling one +needs write access. Exporting one needs export access. + +**Compiling one checked nothing at all.** Anyone who could read a type could ask the platform to +build it — which schedules a real compilation and records an activity under the node, both of them +things a read-only visitor has no business starting. + +The odd part is that the permission for this already existed and was already set up correctly. +*Compile* is exactly the entitlement an editor has and a viewer does not — it is what "may ship a +release" means. It was simply never consulted. So this is less a new rule than a rule that was +written down and never read. + +Compiling now requires that permission. In practice nothing changes for the people who do it: +editors, owners and administrators all carry it already. A read-only visitor gets a refusal that +names what to ask for instead of a build nobody authorised. + +**A refusal and a failed check are kept apart**, which matters more than it sounds. If the platform +cannot *establish* your permissions — a momentary hiccup rather than a decision — you are told to try +again, not told you lack access. Telling someone they need a permission they already hold sends them +off to request something they own, and that mistake is easy to make and hard to notice. diff --git a/src/MeshWeaver.Mesh.Operations/MeshOperations.cs b/src/MeshWeaver.Mesh.Operations/MeshOperations.cs index 8f9b5a2d98..b1dc7dd667 100644 --- a/src/MeshWeaver.Mesh.Operations/MeshOperations.cs +++ b/src/MeshWeaver.Mesh.Operations/MeshOperations.cs @@ -3739,6 +3739,25 @@ public IObservable Recycle(string path) /// their own refusal (the portal's typed error card, via AreaErrorClassifier), and that /// one IS localised. /// + /// + /// What says when the caller is definitively refused (issue #3128). + /// Names the permission by the name it has in the role table, so the reader can ask for the + /// right thing — Compile is what Editor carries and Viewer does not. + /// + internal const string CompileDeniedMessage = + "Compile requires Compile permission on the target NodeType — it schedules a Roslyn build " + + "and records an activity under the node. Ask someone with editor access to the node (or a " + + "platform admin) to do it."; + + /// + /// What says when the permission check reached NO verdict. Says the + /// opposite thing to on purpose: retry, do not go asking for + /// a permission you may well already hold. Twin of . + /// + internal const string CompileUndeterminedMessage = + "Compile could not be authorized because the permission check did not complete — this is " + + "not a statement about your access. Try again in a moment."; + internal const string RecycleDeniedMessage = "Recycle requires Update permission on the target node — it disposes the node's hub and " + "forces re-initialization. Ask someone with write access to the node (or a platform " @@ -4140,7 +4159,57 @@ public IObservable Compile(string path) new { status = "Error", message = "path is required" }, hub.JsonSerializerOptions)); - return Observable.Defer(() => + // 🚨 PERMISSION GATE — issue #3128. Compile carried NO check at all: `Recycle` requires + // Update on the target and `Export` requires Export per node, and a caller who may only READ + // a NodeType could still trigger a Roslyn compile of it and mint an `_Activity` node + // underneath it. Measured on a real-RLS monolith mesh with a Viewer, through a real session + // hub: the Update gate answered granted=False and Compile answered `{"status":"Ok"}` anyway. + // + // 🚨 The permission is `Compile`, not `Update`. Both were defensible — `Recycle` is the + // nearest neighbour and requires Update — but `Permission.Compile` already exists and is + // already scoped for exactly this: Role.Editor carries it ("Space editors ship releases by + // default"), Role.Viewer and Role.Commenter do not, and Admin/PlatformAdmin add it + // EXPLICITLY because it is deliberately excluded from Permission.All. So the entitlement was + // modelled and simply never consulted; Update would have invented a second answer to a + // question the permission table had already settled. + // + // A bare Take(1), like Recycle's and for the same reason: leaving the fold's gate also + // leaves the HUB'S TURN, and the trigger stamped below must stay ordered against the + // caller's turn. + // + // The tri-state, not a swallow (#2901/#2742): a fold that merely hiccuped or completed + // without emitting must not be reported as "you may not compile this", which would send a + // caller who already holds Compile to go and request it. Still fail-closed — nothing is + // triggered without a positive verdict. + return hub.CheckPermissionOutcome(resolvedPath, MeshWeaver.Mesh.Security.Permission.Compile) + .Take(1) + .Timeout(TimeSpan.FromSeconds(10)) + .Catch((Exception ex) => Observable.Return( + MeshWeaver.Mesh.Security.PermissionCheckOutcome.Undetermined( + $"the permission check for Compile on '{resolvedPath}' did not complete: " + + $"{ex.GetType().Name}"))) + .SelectMany(outcome => + { + if (outcome.IsGranted) + return CompileCore(); + + if (outcome.IsUndetermined) + { + logger.LogWarning( + "Compile: the permission fold for {Path} reached NO verdict — reporting it " + + "as retryable, not as a denial: {Reason}", + resolvedPath, outcome.UndeterminedReason); + return Observable.Return(JsonSerializer.Serialize( + new { status = "Error", path = resolvedPath, message = CompileUndeterminedMessage }, + hub.JsonSerializerOptions)); + } + + return Observable.Return(JsonSerializer.Serialize( + new { status = "Error", path = resolvedPath, message = CompileDeniedMessage }, + hub.JsonSerializerOptions)); + }); + + IObservable CompileCore() => Observable.Defer(() => { IWorkspace workspace; try { workspace = hub.GetWorkspace(); } diff --git a/test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs b/test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs index 4cdf1135be..876d1490a1 100644 --- a/test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs +++ b/test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs @@ -317,6 +317,81 @@ private static DeliveryFailure Nack(ErrorType errorType, string message) /// the message field of the JSON envelope. A THROW propagates: the whole point is that /// the operation answers. /// + /// + /// 🚨 Issue #3128 — Compile carried NO permission check at all. + /// + /// Recycle requires Update on the target and Export requires + /// Export per node; Compile required nothing, so a caller who may only READ a + /// NodeType could trigger a Roslyn build of it — and mint an _Activity node underneath + /// it. This was measured BOTH before and after #3127: that PR fixed the session hub's inert + /// evaluator, which is why create / recycle / export changed answer there + /// and Compile did not. There was no check for the repaired evaluator to answer. + /// + /// The refusal envelope is itself the proof that nothing ran: the activity is minted + /// inside the compile body, which the denial arm returns in front of. + /// + [Fact] + public async Task CompilingANodeTypeWithoutCompilePermissionIsRefusedNotPerformed() + { + ActAsViewer(); + var answer = await Compile(NodeTypePath); + Output.WriteLine($"Compile({NodeTypePath}) as viewer → {answer}"); + + using var envelope = JsonDocument.Parse(answer); + envelope.RootElement.GetProperty("status").GetString().Should().Be("Error", + "the operation ran and produced a refusal, so its envelope must say so — a raised " + + "exception is not an answer"); + envelope.RootElement.GetProperty("message").GetString().Should().Be( + MeshOperations.CompileDeniedMessage, + "a Viewer holds Read|Execute|Api and no Compile — pre-fix this answered " + + "{\"status\":\"Ok\",\"message\":\"Compile SUCCEEDED.\"} and left an _Activity node " + + "behind it (#3128)"); + } + + /// + /// 🚨 The case that could have FALSIFIED the one above. A gate that refused everybody would + /// satisfy it just as well, and would be a worse bug than the hole it closes — Compile is + /// how a Space editor ships a release. + /// + /// Asserts the entitlement split the role table already declares: Role.Editor + /// carries Permission.Compile ("Space editors ship releases by default"), + /// Role.Viewer does not. Note that Compile is deliberately excluded from + /// Permission.All, which is exactly why Admin and PlatformAdmin add it explicitly — so + /// "does the holder have it?" cannot be answered by an All check. + /// + /// Deliberately does NOT assert a successful compile: whether Roslyn then succeeds is not + /// what this gate decides, and pinning it here would make an access test fail for a compiler + /// reason. Not-the-denial is the whole claim. + /// + [Fact] + public async Task AnEditorHoldsCompile_AndTheGateLetsItThrough() + { + var editorMayCompile = await Mesh + .CheckPermissionOutcome(NodeTypePath, EditorUser, Permission.Compile) + .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); + + editorMayCompile.IsGranted.Should().BeTrue( + "Role.Editor is declared with Permission.Compile — if this flips, the gate above is " + + "refusing the very role the permission was modelled for"); + + ActAs(EditorUser); + var answer = await Compile(NodeTypePath); + Output.WriteLine($"Compile({NodeTypePath}) as editor → {answer}"); + + using var envelope = JsonDocument.Parse(answer); + var message = envelope.RootElement.TryGetProperty("message", out var m) ? m.GetString() : null; + message.Should().NotBe(MeshOperations.CompileDeniedMessage, + "an Editor holds Compile, so the gate must pass the call through to the compile body — " + + "a gate that refuses everyone would pass the Viewer fact and break every release"); + message.Should().NotBe(MeshOperations.CompileUndeterminedMessage, + "the fold reached a verdict for this caller a line above, so 'we could not check' here " + + "would mean the gate is asking a different question than the assertion that armed it"); + } + + private async Task Compile(string path) => + await new MeshOperations(SessionHub()).Compile(path) + .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); + private async Task RecycleAsViewer(string path) { ActAsViewer();