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
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 70 additions & 1 deletion src/MeshWeaver.Mesh.Operations/MeshOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3739,6 +3739,25 @@ public IObservable<string> Recycle(string path)
/// their own refusal (the portal's typed error card, via <c>AreaErrorClassifier</c>), and that
/// one IS localised.</para>
/// </summary>
/// <summary>
/// What <see cref="Compile"/> 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 — <c>Compile</c> is what Editor carries and Viewer does not.
/// </summary>
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.";

/// <summary>
/// What <see cref="Compile"/> says when the permission check reached NO verdict. Says the
/// opposite thing to <see cref="CompileDeniedMessage"/> on purpose: retry, do not go asking for
/// a permission you may well already hold. Twin of <see cref="RecycleUndeterminedMessage"/>.
/// </summary>
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 "
Expand Down Expand Up @@ -4140,7 +4159,57 @@ public IObservable<string> 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<string> CompileCore() => Observable.Defer(() =>
{
IWorkspace workspace;
try { workspace = hub.GetWorkspace(); }
Expand Down
75 changes: 75 additions & 0 deletions test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,81 @@ private static DeliveryFailure Nack(ErrorType errorType, string message)
/// the <c>message</c> field of the JSON envelope. A THROW propagates: the whole point is that
/// the operation answers.
/// </summary>
/// <summary>
/// 🚨 <b>Issue #3128 — <c>Compile</c> carried NO permission check at all.</b>
///
/// <para><c>Recycle</c> requires <c>Update</c> on the target and <c>Export</c> requires
/// <c>Export</c> per node; <c>Compile</c> required nothing, so a caller who may only READ a
/// NodeType could trigger a Roslyn build of it — and mint an <c>_Activity</c> node underneath
/// it. This was measured BOTH before and after #3127: that PR fixed the session hub's inert
/// evaluator, which is why <c>create</c> / <c>recycle</c> / <c>export</c> changed answer there
/// and <c>Compile</c> did not. There was no check for the repaired evaluator to answer.</para>
///
/// <para>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.</para>
/// </summary>
[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)");
}

/// <summary>
/// 🚨 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 — <c>Compile</c> is
/// how a Space editor ships a release.
///
/// <para>Asserts the entitlement split the role table already declares: <c>Role.Editor</c>
/// carries <c>Permission.Compile</c> ("Space editors ship releases by default"),
/// <c>Role.Viewer</c> does not. Note that <c>Compile</c> is deliberately excluded from
/// <c>Permission.All</c>, which is exactly why Admin and PlatformAdmin add it explicitly — so
/// "does the holder have it?" cannot be answered by an <c>All</c> check.</para>
///
/// <para>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.</para>
/// </summary>
[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<string> Compile(string path) =>
await new MeshOperations(SessionHub()).Compile(path)
.FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken);

private async Task<string?> RecycleAsViewer(string path)
{
ActAsViewer();
Expand Down
Loading